diff --git a/.editorconfig b/.editorconfig index 25189e6245..cdbf76b26b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -387,6 +387,9 @@ dotnet_diagnostic.IDE2005.severity = warning # MA0001: StringComparison is missing dotnet_diagnostic.MA0001.severity = warning +# MA0004: Use ConfigureAwait when awaiting a task +dotnet_diagnostic.MA0004.severity = none + # MA0015: Do not call overridable members in constructor dotnet_diagnostic.MA0056.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 833fa20de1..57d2e7ea1f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/CustomizedDialog.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/CustomizedDialog.razor new file mode 100644 index 0000000000..fcf0617dda --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/CustomizedDialog.razor @@ -0,0 +1,61 @@ + + + + @Dialog.Options.Header.Title + + + + + + Description + @Person.VeryLongDescription + + + + Cancel + OK + + + + +@code { + // If you want to use this razor component in standalone mode, + // you can use a nullable IDialogInstance property. + // If the value is not null, the component is running using the DialogService. + // `public IDialogInstance? FluentDialog { get; set; }` + [CascadingParameter] + public required IDialogInstance Dialog { get; set; } + + [Inject] + public required IDialogService DialogService { get; set; } + + // A simple type is not updatable + [Parameter] + public string? Name { get; set; } + + // A class is updatable + [Parameter] + public PersonDetails Person { get; set; } = new(); + + // A nullable type is optional + [Parameter] + public int? NotAssignedParam { get; set; } + + private async Task btnOK_Click() + { + int.TryParse(Person.Age, out var age); + if (age <= 0) + { + await DialogService.ShowErrorAsync("Age must be a positive number."); + } + else + { + await Dialog.CloseAsync(DialogResult.Ok("Yes")); + } + } + + private async Task btnCancel_Click() + { + await Dialog.CloseAsync(DialogResult.Cancel("No")); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/PersonDetails.cs b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/PersonDetails.cs new file mode 100644 index 0000000000..e21c546bc3 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/PersonDetails.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace FluentUI.Demo.Client.Documentation.Components.Dialog.Examples.Dialog; + +public class PersonDetails +{ + private static readonly string _longDescription = string.Join("", SampleData.Text.LoremIpsum.Select(i => $"

{i}

")); + + public string Age { get; set; } = ""; + + public MarkupString VeryLongDescription => (MarkupString)_longDescription; + + public override string ToString() => $"Age: {Age}"; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor new file mode 100644 index 0000000000..74e5b647ef --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/Dialog/SimpleDialog.razor @@ -0,0 +1,36 @@ +@inherits FluentDialogInstance + + + + + + +@code { + // A simple type is not updatable + [Parameter] + public string? Name { get; set; } + + // A class is updatable + [Parameter] + public PersonDetails Person { get; set; } = new(); + + // Initialize the dialog + protected override void OnInitializeDialog(DialogOptionsHeader header, DialogOptionsFooter footer) + { + header.Title = $"Dialog Title - {Name}"; + footer.SecondaryAction.Visible = true; + } + + // Handle the action click + protected override async Task OnActionClickedAsync(bool primary) + { + if (primary) + { + await DialogInstance.CloseAsync("Yes"); + } + else + { + await DialogInstance.CancelAsync(); + } + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogBodyDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogBodyDefault.razor new file mode 100644 index 0000000000..cfe23e81e5 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogBodyDefault.razor @@ -0,0 +1,16 @@ + + + + My title + + + + @SampleData.Text.GenerateLoremIpsum(paragraphCount: 1) + + + + One + Two + + + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogMessageBox.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogMessageBox.razor new file mode 100644 index 0000000000..d221c63cf1 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogMessageBox.razor @@ -0,0 +1,79 @@ +@inject IDialogService DialogService + +Success +Warning +Error +Information +Confirmation +Long message +Custom message + +

+ Last result: @GetResult() +

+ +@code +{ + private bool? Canceled; + + private async Task ShowSuccessAsync() + { + var result = await DialogService.ShowSuccessAsync("The action was a success"); + Canceled = result.Cancelled; + } + + private async Task ShowWarningAsync() + { + var result = await DialogService.ShowWarningAsync("This is your final warning"); + Canceled = result.Cancelled; + } + + private async Task ShowErrorAsync() + { + var result = await DialogService.ShowErrorAsync("This is an error"); + Canceled = result.Cancelled; + } + + private async Task ShowInformationAsync() + { + var result = await DialogService.ShowInfoAsync("This is a message"); + Canceled = result.Cancelled; + } + + private async Task ShowConfirmationAsync() + { + var result = await DialogService.ShowConfirmationAsync("Are you sure you want to delete this item?
This will also remove any linked items"); + Canceled = result.Cancelled; + } + + private async Task ShowMessageBoxLongAsync() + { + var result = await DialogService.ShowInfoAsync(SampleData.Text.GenerateLoremIpsum(1)); + Canceled = result.Cancelled; + } + + private async Task ShowMessageBoxAsync() + { + var result = await DialogService.ShowMessageBoxAsync(new MessageBoxOptions() + { + Title = "My title", + Message = "My customized message", + Icon = new Icons.Regular.Size24.Games(), + IconColor = Color.Success, + PrimaryButton = "Plus", + SecondaryButton = "Minus", + }); + + Canceled = result.Cancelled; + } + + private string GetResult() + { + return Canceled switch + { + true => "❌ Canceled", + false => "✅ OK", + _ => "" + }; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogServiceCustomized.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogServiceCustomized.razor new file mode 100644 index 0000000000..63b646bb46 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogServiceCustomized.razor @@ -0,0 +1,40 @@ +@inject IDialogService DialogService + +Open Dialog + +
+
+ John: @John +
+ +@code +{ + private Dialog.PersonDetails John = new() { Age = "20" }; + + private async Task OpenDialogAsync(DialogAlignment alignment) + { + var result = await DialogService.ShowDialogAsync(options => + { + options.Header.Title = "Dialog Title"; + options.Alignment = alignment; + options.Style = alignment == DialogAlignment.Default ? "max-height: 400px;" : null; + + options.Parameters.Add(nameof(Dialog.CustomizedDialog.Name), "John"); // Simple type + options.Parameters.Add(nameof(Dialog.CustomizedDialog.Person), John); // Updatable object + + options.OnStateChange = (e) => + { + Console.WriteLine($"State changed: {e.State}"); + }; + }); + + if (result.Cancelled) + { + Console.WriteLine($"Dialog Canceled: {result.Value}"); + } + else + { + Console.WriteLine($"Dialog Saved: {result.Value}"); + } + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogServiceDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogServiceDefault.razor new file mode 100644 index 0000000000..5fa12a1eed --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/Examples/DialogServiceDefault.razor @@ -0,0 +1,36 @@ +@inject IDialogService DialogService + +Open Dialog + +
+
+ John: @John +
+ +@code +{ + private Dialog.PersonDetails John = new() { Age = "20" }; + + private async Task OpenDialogAsync() + { + var result = await DialogService.ShowDialogAsync(options => + { + options.Parameters.Add(nameof(Dialog.SimpleDialog.Name), "John"); // Simple type + options.Parameters.Add(nameof(Dialog.SimpleDialog.Person), John); // Updatable object + + options.OnStateChange = (e) => + { + Console.WriteLine($"State changed: {e.State}"); + }; + }); + + if (result.Cancelled) + { + Console.WriteLine($"Dialog Canceled: {result.Value}"); + } + else + { + Console.WriteLine($"Dialog Saved: {result.Value}"); + } + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/FluentDialog.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/FluentDialog.md new file mode 100644 index 0000000000..cec57e2289 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/FluentDialog.md @@ -0,0 +1,215 @@ +--- +title: Dialog +route: /Dialog +--- + +# Dialog + +A **Dialog** is a supplemental surface that can provide helpful interactions or require someone to take an +action before they can continue their task, like confirming a deletion. + +**Dialogs** are often interruptions, so use them for important actions. +If you need to give someone an update on an action they just took but that they don't need to act on, try a toast. + +## Best practices + +### Do + - Dialog boxes consist of a header (`TitleTemplate`), content (`ChildContent`), and footer (`ActionTemplate`), + which should all be included inside a body (`FluentDialogBody`). + - Validate that people’s entries are acceptable before closing the dialog. Show an inline validation error near the field they must correct. + - Modal dialogs should be used very sparingly—only when it's critical that people make a choice or provide information before they can proceed. + Thee dialogs are generally used for irreversible or potentially destructive tasks. They're typically paired with an backdrop without a light dismiss. + +### Don't + - Don't use more than three buttons between Dialog'`ActionTemplate`. + - Don't open a `FluentDialog` from a `FluentDialog`. + - Don't use a `FluentDialog` with no focusable elements + +## Usage + +The simplest way is to use the DialogService to display a dialog box. +By injecting this service, you have `ShowDialogAsync` methods at your disposal. +You can pass the **type of Dialog** to be displayed and the **options** to be passed to that window. + +Once the user closes the dialog window, the `ShowDialogAsync` method returns a `DialogResult` object containing the return data. + +Any Blazor component can be used as a dialog type. + +```csharp +@inject IDialogService DialogService + +var result = await DialogService.ShowDialogAsync(options => +{ + // Options +}); + +if (result.Cancelled == false) +{ + ... +} +``` + +The **SimpleDialog** window can inherit from the `FluentDialogInstance` class to have access to the `DialogInstance` property, which contains +all parameters defined when calling `ShowDialogAsync`, as well as the `CloseAsync` and `CancelAsync` methods. +Using `CloseAsync` you can pass a return object to the parent component. +It is then retrieved in the `DialogResult` like in the example below. + +```xml +@inherits FluentDialogInstance + + + Content dialog + +``` + +> **Note:** The `FluentDialogBody` component is required to display the dialog window correctly. + +By default, the `FluentDialogInstance` class will offer two buttons, one to validate and one to cancel (**OK** and **Cancel**). +You can override the actions of these buttons by overriding the `OnActionClickedAsync` method. +By default the shortcut key **Enter** is associated with the **OK** button and the shortcut key **Escape** is associated with the **Cancel** button. +But your can update these shortcuts by overriding the `ShortCut` property. + +{{ DialogServiceDefault Files=Code:DialogServiceDefault.razor;SimpleDialog:SimpleDialog.razor;PersonDetails:PersonDetails.cs }} + +## Customized + +The previous `FluentDialogInstance` object is optional. You can create your own custom dialog box in two different ways. + +🔹Using the `ShowDialogAsync` method options to pass parameters to your dialog box. + Several **options** can be used to customize the dialog box, such as styles, title, button text, etc. + +```csharp +var result = await DialogService.ShowDialogAsync(options => +{ + options.Header.Title = "Dialog Title"; + options.Alignment = DialogAlignment.Default; + options.Style = "max-height: 400px;"; + options.Parameters.Add("Name", "John"); // Simple data will send to the dialog "Name" property. +}); +``` + +🔹Using the `FluentDialogBody` component to create a custom dialog box. + This component allows you to create a dialog box with: + - `TitleTemplate`: Title of the dialog box. + - `TitleActionTemplate`: (optional) Action to be performed on the title dismiss icon. + - `ChildContent`: Content of the dialog box. + - `ActionsTemplate`: Footer of the dialog box, containing close and cancel buttons. + + To have a reference to the `DialogInstance` object, you must use the `CascadingParameter` attribute. + This object allows you to retrieve the dialog options and to close the dialog box and pass a return object to the parent component. + +```xml + + + + @Dialog?.Options.Header.Title + + + + Content dialog + + + + Cancel + OK + + + + +@code +{ + [CascadingParameter] + public required IDialogInstance Dialog { get; set; } +} +``` + +> **Notes**: Adding your custom buttons to the dialog box, the default shortcuts **Enter** and **Escape** will no longer work. + +{{ DialogServiceCustomized Files=Code:DialogServiceCustomized.razor;CustomizedDialog:CustomizedDialog.razor;PersonDetails:PersonDetails.cs }} + +## Data exchange between components + +🔹You can easily send data from your main component to the dialog box +by using the `options.Parameters.Add()` method in the `ShowDialogAsync` method options. + +`Parameters` is a dictionary that allows you to send any type of data to the dialog box. +the **key is the name of the property** in the dialog box, and the **value** is the data to be sent. + +**Main component** +```csharp +PersonDetails John = new() { Age = "20" }; + +var result = await DialogService.ShowDialogAsync(options => +{ + options.Parameters.Add(nameof(SimpleDialog.Name), "John"); // Simple type + options.Parameters.Add(nameof(SimpleDialog.Person), John); // Updatable object +}); +``` + +> **Note:** The `nameof` operator is used to avoid typing errors when passing parameters. +> The key must match the name of the property in the dialog box. +> If the key does not match, an exception will be thrown. + +**SimpleDialog** +```csharp + + ... + + +@code { + // A simple type is not updatable + [Parameter] + public string? Name { get; set; } + + // A class is updatable + [Parameter] + public PersonDetails Person { get; set; } = new(); +} +``` + +In .NET, simple types are passed by value, while objects are passed by reference. +So if you modify an object in the dialog box, the changes will also be visible in the main component. +This is not the case for simple types. + +🔹You can also send data from the dialog box to the main component by using the `CloseAsync` method. +This method allows you to pass a return object to the parent component. + +**Dialog box** +```csharp +protected override async Task OnActionClickedAsync(bool primary) +{ + if (primary) + { + await DialogInstance.CloseAsync("You clicked on the OK button"); + } + else + { + await DialogInstance.CancelAsync(); + } +} +``` + +In the main component, you can retrieve the return object from the `DialogResult` object. +And you can check if the dialog box was closed by clicking on the **OK** or **Cancel** button, using the `Cancelled` property. + +> **Note:** The `CancelAsync` method can not pass a return object to the parent component. +> But the `CloseAsync` method can pass a"Canceled" result object to the parent component. +> `await DialogInstance.CloseAsync(DialogResult.Cancel("You clicked on the Cancel button"));` + +## Without the DialogService + +You can also use the `FluentDialogBody` component directly in your component, without using the `DialogService` and the `FluentDialog` component. + +{{ DialogBodyDefault }} + +## API DialogService + +{{ API Type=DialogService }} + +## API FluentDialogBody + +{{ API Type=FluentDialogBody }} + +## API FluentDialog + +{{ API Type=FluentDialog }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/FluentMessageBox.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/FluentMessageBox.md new file mode 100644 index 0000000000..5e5e24c1ab --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Dialog/FluentMessageBox.md @@ -0,0 +1,57 @@ +--- +title: MessageBox +route: /MessageBox +--- + +# MessageBox + +A **MessageBox** is a dialog that is used to display information with a specific intent to the user. +It uses the `DialogService` to display the dialog. + +The `DialogService` is an injected service that can be used into any component. +It exposes methods to show a dialog. For working with a **MessageBox**, the following methods are available: + +- **ShowSuccessAsync**: Shows a dialog with a success (green) icon, a message and an OK button. + +- **ShowWarningAsync**: Shows a dialog with a warning (orange) icon, a message and an OK button. + +- **ShowInfoAsync**: Shows a dialog with an information (gray) icon, a message and an OK button. + +- **ShowErrorAsync**: Shows a dialog with an error (red) icon, a message and an OK button. + +- **ShowConfirmationAsync**: Shows a dialog with a confirmation icon, a message and a Yes/No buttons. + This method returns a `DialogResult` object where `Cancelled = false` if **Yes** is pressed and `Cancelled = true` if **No** is pressed. + +- **ShowMessageBoxAsync**: Shows a dialog with the specified options. + +The **MessageBox** is displayed as a modal dialog box. This means that the user has to click one of the buttons to close the dialog. +It cannot be closed by clicking outside of the dialog. + +Clicking **Primary** button (OK) will return `true` and clicking **Secondary** button (Cancel) will return `false` as the dialog result. +See the Console log for these return values. + +Internally, the `ShowMessageBox` methods call the `ShowDialog` methods. The ShowMessageBox variants are just convenience methods that make ite easier to work. + +## Example + +```csharp +await DialogService.ShowSuccessAsync("The action was a success"); +``` + +```csharp +await DialogService.ShowSuccessAsync(message: "The action was a success", + title: "Success", + button: "Okay"); +``` + +{{ DialogMessageBox }} + +## Shortcuts + +By default, the shortcut key **Enter** is associated with the **Primary** button (OK) +and the shortcut key **Escape** is associated with the **Secondary** button (Cancel). + +If a single **OK** button is displayed, the shortcut keys **Enter** or **Escape** will close the dialog. + +For the Confirmation dialog, the shortcut keys **Enter** and **Y** are associated with the **Primary** button (Yes). +And the shortcut keys **Escape** and **N** are associated with the **Secondary** button (No). diff --git a/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj b/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj index 7be038c2f9..e3fd8b944c 100644 --- a/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj +++ b/examples/Demo/FluentUI.Demo.Client/FluentUI.Demo.Client.csproj @@ -43,7 +43,7 @@ - + diff --git a/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor b/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor index 2f7ab6843d..398a6a2301 100644 --- a/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor +++ b/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor @@ -75,3 +75,6 @@
© Microsoft @DateTime.Now.Year. All rights reserved. + + + diff --git a/examples/Demo/FluentUI.Demo/MyLocalizer.cs b/examples/Demo/FluentUI.Demo/MyLocalizer.cs new file mode 100644 index 0000000000..4c9c36c986 --- /dev/null +++ b/examples/Demo/FluentUI.Demo/MyLocalizer.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components; +using System.Globalization; + +namespace FluentUI.Demo; + +/// +/// Sample of a Custom Localizer, +/// used with `builder.Services.AddFluentUIComponents`. +/// +internal class MyLocalizer : IFluentLocalizer +{ + public string this[string key, params object[] arguments] + { + get + { + // Need to add + // - builder.Services.AddLocalization(); + // - app.UseRequestLocalization(new RequestLocalizationOptions().AddSupportedUICultures(["fr"])); + var language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + + // Returns the French version of the string + if (language == "fr") + { + return key switch + { + "FluentSample_Hello" => "Bonjour", + _ => IFluentLocalizer.GetDefault(key, arguments), + }; + } + + // By default, returns the English version of the string + return IFluentLocalizer.GetDefault(key, arguments); + } + } +} diff --git a/examples/Demo/FluentUI.Demo/Program.cs b/examples/Demo/FluentUI.Demo/Program.cs index c8d32b5718..303bebda93 100644 --- a/examples/Demo/FluentUI.Demo/Program.cs +++ b/examples/Demo/FluentUI.Demo/Program.cs @@ -2,7 +2,6 @@ // MIT License - Copyright (c) Microsoft Corporation. All rights reserved. // ------------------------------------------------------------------------ -using System.Globalization; using FluentUI.Demo.Client; using Microsoft.FluentUI.AspNetCore.Components; @@ -22,7 +21,7 @@ // Add FluentUI services builder.Services.AddFluentUIComponents(config => { - config.Localizer = new MyLocalizer(); + config.Localizer = new FluentUI.Demo.MyLocalizer(); }); // Add Demo server services @@ -56,32 +55,3 @@ .AddAdditionalAssemblies(typeof(FluentUI.Demo.Client._Imports).Assembly); app.Run(); - -internal class MyLocalizer : IFluentLocalizer -{ - public string this[string key, params object[] arguments] - { - get - { - // Need to add - // - builder.Services.AddLocalization(); - // - app.UseRequestLocalization(new RequestLocalizationOptions().AddSupportedUICultures(["fr"])); - var language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; - - Console.WriteLine(language); - - // Returns the French version of the string - if (language == "fr") - { - return key switch - { - "FluentSample_Hello" => "Bonjour", - _ => IFluentLocalizer.GetDefault(key, arguments), - }; - } - - // By default, returns the English version of the string - return IFluentLocalizer.GetDefault(key, arguments); - } - } -} diff --git a/examples/Demo/FluentUI.Demo/appsettings.Development.json b/examples/Demo/FluentUI.Demo/appsettings.Development.json index 0c208ae918..f8063ef188 100644 --- a/examples/Demo/FluentUI.Demo/appsettings.Development.json +++ b/examples/Demo/FluentUI.Demo/appsettings.Development.json @@ -4,5 +4,6 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - } + }, + "DetailedErrors": true } diff --git a/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj b/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj index e796a50a46..78d87e5074 100644 --- a/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj +++ b/src/Core.Scripts/Microsoft.FluentUI.AspNetCore.Components.Scripts.esproj @@ -1,4 +1,4 @@ - + dist\ Microsoft.FluentUI.AspNetCore.Components diff --git a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts new file mode 100644 index 0000000000..4798f4b6a4 --- /dev/null +++ b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts @@ -0,0 +1,20 @@ +export namespace Microsoft.FluentUI.Blazor.Components.Dialog { + + /** + * Display the fluent-dialog with the given id + * @param id The id of the fluent-dialog to display + */ + export function Show(id: string): void { + const dialog = document.getElementById(id) as any; + dialog?.show(); + } + + /** + * Hide the fluent-dialog with the given id + * @param id The id of the fluent-dialog to hide + */ + export function Hide(id: string): void { + const dialog = document.getElementById(id) as any; + dialog?.hide(); + } +} diff --git a/src/Core.Scripts/src/ExportedMethods.ts b/src/Core.Scripts/src/ExportedMethods.ts new file mode 100644 index 0000000000..2440ffc7f9 --- /dev/null +++ b/src/Core.Scripts/src/ExportedMethods.ts @@ -0,0 +1,27 @@ +import { Microsoft as LoggerFile } from './Utilities/Logger'; +import { Microsoft as FluentDialogFile } from './Components/Dialog/FluentDialog'; + +export namespace Microsoft.FluentUI.Blazor.ExportedMethods { + + /** + * Initializes the common methods to use with the Fluent UI Blazor library. + */ + export function initialize() { + + // Create the Microsoft.FluentUI.Blazor namespace + (window as any).Microsoft = (window as any).Microsoft || {}; + (window as any).Microsoft.FluentUI = (window as any).Microsoft.FluentUI || {}; + (window as any).Microsoft.FluentUI.Blazor = (window as any).Microsoft.FluentUI.Blazor || {}; + + // Utilities methods (Logger) + (window as any).Microsoft.FluentUI.Blazor.Utilities = (window as any).Microsoft.FluentUI.Blazor.Utilities || {}; + (window as any).Microsoft.FluentUI.Blazor.Utilities.Logger = LoggerFile.FluentUI.Blazor.Utilities.Logger; + + // Dialog methods + (window as any).Microsoft.FluentUI.Blazor.Components = (window as any).Microsoft.FluentUI.Blazor.Components || {}; + (window as any).Microsoft.FluentUI.Blazor.Components.Dialog = FluentDialogFile.FluentUI.Blazor.Components.Dialog; + + // [^^^ Add your other exported methods before this line ^^^] + } +} + diff --git a/src/Core.Scripts/src/FluentUICustomEvents.ts b/src/Core.Scripts/src/FluentUICustomEvents.ts index 948a821097..4c1d77c2d1 100644 --- a/src/Core.Scripts/src/FluentUICustomEvents.ts +++ b/src/Core.Scripts/src/FluentUICustomEvents.ts @@ -8,21 +8,34 @@ export namespace Microsoft.FluentUI.Blazor.FluentUICustomEvents { * E.g. `FluentUICustomEvents.RadioGroup(blazor);` */ + export function DialogToggle(blazor: Blazor) { - // TODO: Example of custom event for "not yet existing" RadioGroup component - export function RadioGroup(blazor: Blazor) { - - blazor.registerCustomEventType('radiogroupclick', { - browserEventName: 'click', + blazor.registerCustomEventType('dialogbeforetoggle', { + browserEventName: 'beforetoggle', createEventArgs: event => { - if (event.target.readOnly || event.target.disabled) { - return null; - } return { - value: event.target.value + id: event.target.id, + type: event.type, + oldState: event.detail.oldState, + newState: event.detail.newState, }; } }); + blazor.registerCustomEventType('dialogtoggle', { + browserEventName: 'toggle', + createEventArgs: event => { + return { + id: event.target.id, + type: event.type, + oldState: event.detail.oldState, + newState: event.detail.newState, + }; + } + }); } + + + // [^^^ Add your other custom events before this line ^^^] + } diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts index 064c097995..95748c4ca6 100644 --- a/src/Core.Scripts/src/Startup.ts +++ b/src/Core.Scripts/src/Startup.ts @@ -49,7 +49,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { // [^^^ Add your other custom components before this line ^^^] // Register all custom events - FluentUICustomEvents.RadioGroup(blazor); + FluentUICustomEvents.DialogToggle(blazor); // [^^^ Add your other custom events before this line ^^^] // Finishing diff --git a/src/Core.Scripts/src/index.ts b/src/Core.Scripts/src/index.ts index e12d4ce2cc..3d13a5af64 100644 --- a/src/Core.Scripts/src/index.ts +++ b/src/Core.Scripts/src/index.ts @@ -13,6 +13,10 @@ - All FluentUI WebComponents are defined and initialized in the `FluentUIWebComponents` file. */ +/* ***********************************************/ +/* ⚠️ PLEASE DO NOT MODIFY ANYTHING IN THIS FILE */ +/* ***********************************************/ + import { Microsoft as StartupFile } from './Startup'; import Startup = StartupFile.FluentUI.Blazor.Startup; @@ -25,3 +29,6 @@ export const afterWebStarted = Startup.afterWebStarted; export const afterServerStarted = Startup.afterServerStarted; export const afterWebAssemblyStarted = Startup.afterWebAssemblyStarted; +// Initialize the common methods to use with the Fluent UI Blazor library. +import { Microsoft as ExportedMethodsFile } from './ExportedMethods'; +ExportedMethodsFile.FluentUI.Blazor.ExportedMethods.initialize(); diff --git a/src/Core/Components/Base/FluentComponentBase.cs b/src/Core/Components/Base/FluentComponentBase.cs index c1d5c37499..1b8e1dcd47 100644 --- a/src/Core/Components/Base/FluentComponentBase.cs +++ b/src/Core/Components/Base/FluentComponentBase.cs @@ -17,7 +17,7 @@ public abstract class FluentComponentBase : ComponentBase, IAsyncDisposable, IFl /// [Inject] - private IJSRuntime JSRuntime { get; set; } = default!; + protected IJSRuntime JSRuntime { get; set; } = default!; /// [Inject] diff --git a/src/Core/Components/Base/FluentJSModule.cs b/src/Core/Components/Base/FluentJSModule.cs index 9d451dc7c1..bf97f0d363 100644 --- a/src/Core/Components/Base/FluentJSModule.cs +++ b/src/Core/Components/Base/FluentJSModule.cs @@ -7,8 +7,6 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -#pragma warning disable MA0004 // Use Task.ConfigureAwait - /// /// Base class to manage the JavaScript function from the FluentUI Blazor components. /// @@ -91,6 +89,4 @@ internal virtual ValueTask DisposeAsync(IJSObjectReference? jsModule) { return ValueTask.CompletedTask; } -} - -#pragma warning restore MA0004 // Use Task.ConfigureAwait +} \ No newline at end of file diff --git a/src/Core/Components/Base/FluentServiceBase.cs b/src/Core/Components/Base/FluentServiceBase.cs new file mode 100644 index 0000000000..95088a609f --- /dev/null +++ b/src/Core/Components/Base/FluentServiceBase.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// +/// +/// +public abstract class FluentServiceBase : IFluentServiceBase +{ + private readonly ConcurrentDictionary _list = []; + + /// + /// + /// + string? IFluentServiceBase.ProviderId { get; set; } + + /// + /// + /// + ConcurrentDictionary IFluentServiceBase.Items => _list; + + /// + /// + /// + Func IFluentServiceBase.OnUpdatedAsync { get; set; } = (item) => Task.CompletedTask; + + /// + /// Gets the current instance of the service, + /// converted to the interface. + /// + internal IFluentServiceBase ServiceProvider => this; + + /// + /// + /// + public void Dispose() + { + ServiceProvider.Items.Clear(); + } +} diff --git a/src/Core/Components/Base/FluentServiceProviderException.cs b/src/Core/Components/Base/FluentServiceProviderException.cs new file mode 100644 index 0000000000..6310f0f151 --- /dev/null +++ b/src/Core/Components/Base/FluentServiceProviderException.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Exception thrown when a service provider is not available. +/// +public class FluentServiceProviderException : Exception +{ + /// + /// Creates a new instance of the class. + /// + public FluentServiceProviderException() + : base($"{typeof(TProvider).Name} needs to be added to the page/component hierarchy of your application/site. Usually this will be 'MainLayout' but depending on your setup it could be at a different location.") + { + + } +} diff --git a/src/Core/Components/Base/IFluentServiceBase.cs b/src/Core/Components/Base/IFluentServiceBase.cs new file mode 100644 index 0000000000..b03c4cbd58 --- /dev/null +++ b/src/Core/Components/Base/IFluentServiceBase.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Common interface for a service provider (DialogService, MenuService, ...). +/// +/// +public interface IFluentServiceBase : IDisposable +{ + /// + /// Gets the ServiceProvider ID. + /// This value is set by the provider and will be empty if the provider is not initialized. + /// + public string? ProviderId { get; internal set; } + + /// + /// Gets the list of . + /// + internal ConcurrentDictionary Items { get; } + + /// + /// Action to be called when the is updated. + /// + internal Func OnUpdatedAsync { get; set; } +} diff --git a/src/Core/Components/Dialog/DialogEventArgs.cs b/src/Core/Components/Dialog/DialogEventArgs.cs new file mode 100644 index 0000000000..3253da802a --- /dev/null +++ b/src/Core/Components/Dialog/DialogEventArgs.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Event arguments for the FluentDialog component. +/// +public class DialogEventArgs : EventArgs +{ + /// + internal DialogEventArgs(FluentDialog dialog, DialogToggleEventArgs args) + : this(dialog, args.Id, args.Type, args.OldState, args.NewState) + { + } + + /// + internal DialogEventArgs(FluentDialog dialog, string? id, string? eventType, string? oldState, string? newState) + { + 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; + } + } + + /// + internal DialogEventArgs(IDialogInstance instance, DialogState state) + { + Id = instance.Id; + Instance = instance; + State = state; + } + + /// + /// Gets the ID of the FluentDialog component. + /// + public string Id { get; } + + /// + /// Gets the state of the FluentDialog component. + /// + public DialogState State { get; } + + /// + /// Gets the instance used by the . + /// + public IDialogInstance? Instance { get; } +} diff --git a/src/Core/Components/Dialog/FluentDialog.razor b/src/Core/Components/Dialog/FluentDialog.razor new file mode 100644 index 0000000000..0422f004fb --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialog.razor @@ -0,0 +1,29 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase + + + + @GetDialogStyle() + + @if (Instance is not null) + { + + + + } + else + { + @ChildContent + } + + diff --git a/src/Core/Components/Dialog/FluentDialog.razor.cs b/src/Core/Components/Dialog/FluentDialog.razor.cs new file mode 100644 index 0000000000..7c0aed97c3 --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialog.razor.cs @@ -0,0 +1,263 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The dialog component is a window overlaid on either the primary window or another dialog window. +/// Windows under a modal dialog are inert. +/// +public partial class FluentDialog : FluentComponentBase +{ + /// + public FluentDialog() + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => new CssBuilder(Class) + .Build(); + + /// + protected string? StyleValue => new StyleBuilder(Style) + .AddStyle("height", Instance?.Options.Height, when: IsDialog()) + .AddStyle("width", Instance?.Options.Width) + .Build(); + + /// + [Inject] + private IDialogService? DialogService { get; set; } + + /// + /// Gets or sets the instance used by the . + /// + [Parameter] + public IDialogInstance? Instance { get; set; } + + /// + /// Used when not calling the to show a dialog. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the alignment of the dialog (center, left, right). + /// + [Parameter] + public DialogAlignment Alignment { get; set; } = DialogAlignment.Default; + + ///// + ///// Gets or sets a value indicating whether this dialog is displayed modally. + ///// + ///// + ///// When a dialog is displayed modally, no input (keyboard or mouse click) can occur except to objects on the modal dialog. + ///// The program must hide or close a modal dialog (usually in response to some user action) before input to another dialog can occur. + ///// + //[Parameter] + //public bool Modal { get; set; } + + /// + /// Command executed when the user clicks on the button. + /// + [Parameter] + public EventCallback OnStateChange { get; set; } + + /// + /// + /// + /// + /// + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && LaunchedFromService) + { + var instance = Instance as DialogInstance; + if (instance is not null) + { + instance.FluentDialog = this; + } + + return ShowAsync(); + } + + return Task.CompletedTask; + } + + /// + internal async Task OnToggleAsync(DialogToggleEventArgs args) + { + // Raise the event received from the Web Component + var dialogEventArgs = await RaiseOnStateChangeAsync(args); + + if (LaunchedFromService) + { + switch (dialogEventArgs.State) + { + // Set the result of the dialog + case DialogState.Closing: + (Instance as DialogInstance)?.ResultCompletion.TrySetResult(DialogResult.Cancel()); + break; + + // Remove the dialog from the DialogProvider + case DialogState.Closed: + (DialogService as DialogService)?.RemoveDialogFromProviderAsync(Instance); + break; + } + } + } + + /// + private async Task RaiseOnStateChangeAsync(DialogEventArgs args) + { + if (OnStateChange.HasDelegate) + { + await InvokeAsync(() => OnStateChange.InvokeAsync(args)); + } + + return args; + } + + /// + private Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) => RaiseOnStateChangeAsync(new DialogEventArgs(this, args)); + + /// + internal Task RaiseOnStateChangeAsync(IDialogInstance instance, DialogState state) => RaiseOnStateChangeAsync(new DialogEventArgs(instance, state)); + + /// + /// Displays the dialog. + /// + [ExcludeFromCodeCoverage] + public async Task ShowAsync() + { + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.Dialog.Show", Id); + } + + /// + /// Hide the dialog. + /// + [ExcludeFromCodeCoverage] + public async Task HideAsync() + { + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.Dialog.Hide", Id); + } + + /// + private bool LaunchedFromService => Instance is not null; + + /// + private async Task OnKeyDownHandlerAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e) + { + if (Instance is null) + { + return; + } + + var shortCut = $"{(e.CtrlKey ? "Ctrl+" : string.Empty)}{(e.AltKey ? "Alt+" : string.Empty)}{(e.ShiftKey ? "Shift+" : string.Empty)}{e.Key}"; + + // OK button + var primaryPressed = await ShortCutPressedAsync(Instance.Options.Footer.PrimaryAction, shortCut, Instance.CloseAsync); + if (primaryPressed) + { + return; + } + + // Cancel button + var secondaryPressed = await ShortCutPressedAsync(Instance.Options.Footer.SecondaryAction, shortCut, Instance.CancelAsync); + if (secondaryPressed) + { + return; + } + + // Call the OnClickAsync or defaultAction if the shortcut is the button.ShortCut. + async Task ShortCutPressedAsync(DialogOptionsFooterAction button, string shortCut, Func defaultAction) + { + if (string.IsNullOrEmpty(button.ShortCut) || Instance is null || !button.ToDisplay) + { + return false; + } + + var buttonShortcuts = button.ShortCut.Split(";"); + foreach (var buttonShortcut in buttonShortcuts) + { + + if (string.Equals(buttonShortcut.Trim(), shortCut, StringComparison.OrdinalIgnoreCase)) + { + if (button.OnClickAsync is not null) + { + await button.OnClickAsync.Invoke(Instance); + } + else + { + await defaultAction.Invoke(); + } + + return true; + } + } + + return false; + } + } + + /// + private string? GetAlignmentAttribute() + { + // Alignment is only used when the dialog is a panel. + if (IsPanel()) + { + // Get the alignment from the DialogService (if used) or the Alignment property. + var alignment = Instance?.Options.Alignment ?? Alignment; + + return alignment switch + { + DialogAlignment.Start => "start", + DialogAlignment.End => "end", + _ => null + }; + } + + return null; + } + + /// + private string? GetModalAttribute() + { + switch (IsPanel()) + { + // FluentDialog + case false: + // TODO: To find a way to catch the click event outside the dialog. + return "alert"; + + // Panels + case true: + // TODO: To find a way to catch the click event outside the dialog. + return "alert"; + + } + } + + /// + private bool IsPanel() => (Instance?.Options.Alignment ?? Alignment) != DialogAlignment.Default; + + /// + private bool IsDialog() => !IsPanel(); + + /// + private MarkupString? GetDialogStyle() + { + if (string.IsNullOrEmpty(StyleValue)) + { + return null; + } + + return (MarkupString)$""; + } +} diff --git a/src/Core/Components/Dialog/FluentDialog.razor.css b/src/Core/Components/Dialog/FluentDialog.razor.css new file mode 100644 index 0000000000..28cb7ef51c --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialog.razor.css @@ -0,0 +1,11 @@ +fluent-dialog[position="end"][fuib]::part(dialog) { + margin-inline-end: 0px; + max-height: 100vh; + border-radius: 0px; +} + +fluent-dialog[position="start"][fuib]::part(dialog) { + margin-inline-start: 0px; + max-height: 100vh; + border-radius: 0px; +} diff --git a/src/Core/Components/Dialog/FluentDialogBody.razor b/src/Core/Components/Dialog/FluentDialogBody.razor new file mode 100644 index 0000000000..cee4796865 --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialogBody.razor @@ -0,0 +1,53 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Rendering +@inherits FluentComponentBase + + + + @* Header Title *@ + @if (TitleTemplate is not null) + { +
@TitleTemplate
+ } + else if (!string.IsNullOrEmpty(Instance?.Options.Header.Title)) + { +
@((MarkupString)(Instance.Options.Header.Title))
+ } + + @* Header Action *@ + @if (TitleActionTemplate is not null) + { +
@TitleActionTemplate
+ } + + @* Content *@ + @if (ChildContent is not null) + { +
@ChildContent
+ } + + @* Footer *@ + @if (ActionTemplate is not null) + { +
@ActionTemplate
+ } + else if (Instance?.Options.Footer.HasActions == true) + { +
+ + @foreach (var action in Instance.Options.Footer.Actions) + { + if (action.ToDisplay) + { + + @action.Label + + } + } + +
+ } + +
diff --git a/src/Core/Components/Dialog/FluentDialogBody.razor.cs b/src/Core/Components/Dialog/FluentDialogBody.razor.cs new file mode 100644 index 0000000000..47e66bd5ab --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialogBody.razor.cs @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The dialog component is a window overlaid on either the primary window or another dialog window. +/// Windows under a modal dialog are inert. +/// +public partial class FluentDialogBody : FluentComponentBase +{ + /// + protected string? ClassValue => new CssBuilder(Class) + .Build(); + + /// + protected string? StyleValue => new StyleBuilder(Style) + .Build(); + + /// + [CascadingParameter] + private IDialogInstance? Instance { get; set; } + + /// + /// Gets or sets the content to be rendered inside the component. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the content for the title element. + /// + [Parameter] + public RenderFragment? TitleTemplate { get; set; } + + /// + /// Gets or sets the content for the action element in the title. + /// + [Parameter] + public RenderFragment? TitleActionTemplate { get; set; } + + /// + /// Gets or sets the content for the action element. + /// + [Parameter] + public RenderFragment? ActionTemplate { get; set; } + + /// + internal async Task ActionClickHandlerAsync(DialogOptionsFooterAction item) + { + if (item.Disabled || Instance is null) + { + return; + } + + if (item.OnClickAsync is not null ) + { + await item.OnClickAsync(Instance); + } + else + { + var result = item.Appearance == ButtonAppearance.Primary ? DialogResult.Ok() : DialogResult.Cancel(); + await Instance.CloseAsync(result); + } + } +} diff --git a/src/Core/Components/Dialog/FluentDialogBody.razor.css b/src/Core/Components/Dialog/FluentDialogBody.razor.css new file mode 100644 index 0000000000..a4a217e854 --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialogBody.razor.css @@ -0,0 +1,18 @@ +fluent-dialog[position][fuib] fluent-dialog-body[fuib] { + grid-template-rows: auto 1fr auto; + height: 100%; +} + + /* Desktop and Mobile */ + fluent-dialog-body[fuib] > div[slot="action"] { + display: flex; + gap: var(--spacingVerticalS); + flex-direction: column; + } + +/* Desktop */ +@container (min-width: 480px) { + fluent-dialog-body[fuib] > div[slot="action"] { + flex-direction: row; + } +} diff --git a/src/Core/Components/Dialog/FluentDialogInstance.cs b/src/Core/Components/Dialog/FluentDialogInstance.cs new file mode 100644 index 0000000000..733e450b83 --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialogInstance.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Localization; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// +/// +public abstract class FluentDialogInstance : ComponentBase +{ + /// + /// Gets or sets the dialog instance. + /// + [CascadingParameter] + public virtual required IDialogInstance DialogInstance { get; set; } + + /// + /// Gets or sets the localizer. + /// + [Inject] + public virtual required IFluentLocalizer Localizer { get; set; } + + /// + /// Method invoked when the component is ready to start, having received its + /// initial parameters from its parent in the render tree. + /// + /// Override this method if you will perform an asynchronous operation and + /// want the component to refresh when that operation is completed. + /// + /// + /// + protected virtual void OnInitializeDialog(DialogOptionsHeader header, DialogOptionsFooter footer) + { + } + + /// + /// Configures the dialog header and footer. + /// + /// + protected override Task OnInitializedAsync() + { + var footer = DialogInstance.Options.Footer; + footer.PrimaryAction.Label ??= Localizer[LanguageResource.MessageBox_ButtonOk]; + footer.PrimaryAction.OnClickAsync ??= (e) => OnActionClickedAsync(primary: true); + footer.SecondaryAction.Label ??= Localizer[LanguageResource.MessageBox_ButtonCancel]; + footer.SecondaryAction.OnClickAsync ??= (e) => OnActionClickedAsync(primary: false); + + OnInitializeDialog(DialogInstance.Options.Header, DialogInstance.Options.Footer); + + return base.OnInitializedAsync(); + } + + /// + /// Method invoked when an action is clicked. + /// + /// Override this method if you will perform an asynchronous operation + /// when the user clicks an action button. + /// + /// + protected abstract Task OnActionClickedAsync(bool primary); +} diff --git a/src/Core/Components/Dialog/FluentDialogProvider.razor b/src/Core/Components/Dialog/FluentDialogProvider.razor new file mode 100644 index 0000000000..7182af2252 --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialogProvider.razor @@ -0,0 +1,18 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase + +
+ @if (DialogService != null) + { + @foreach (var dialog in DialogService.Items.Values.OrderBy(i => i.Index)) + { + + } + } +
diff --git a/src/Core/Components/Dialog/FluentDialogProvider.razor.cs b/src/Core/Components/Dialog/FluentDialogProvider.razor.cs new file mode 100644 index 0000000000..b9482aed2e --- /dev/null +++ b/src/Core/Components/Dialog/FluentDialogProvider.razor.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public partial class FluentDialogProvider : FluentComponentBase +{ + private IDialogService? _dialogService; + + /// + public FluentDialogProvider() + { + Id = Identifier.NewId(); + } + + /// + internal string? ClassValue => new CssBuilder(Class) + .AddClass("fluent-dialog-provider") + .Build(); + + /// + internal string? StyleValue => new StyleBuilder(Style) + .AddStyle("z-index", ZIndex.Dialog.ToString(CultureInfo.InvariantCulture)) + .Build(); + + /// + /// Gets or sets the injected service provider. + /// + [Inject] + public IServiceProvider? ServiceProvider { get; set; } + + /// + protected virtual IDialogService? DialogService => _dialogService ??= ServiceProvider?.GetService(); + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + if (DialogService is not null) + { + DialogService.ProviderId = Id; + DialogService.OnUpdatedAsync = async (item) => + { + await InvokeAsync(StateHasChanged); + }; + } + } + + /// + private static Action EmptyOnStateChange => (_) => { }; + + /// + /// Only for Unit Tests + /// + /// + internal void UpdateId(string? id) + { + Id = id; + + if (DialogService is not null) + { + DialogService.ProviderId = id; + } + } +} diff --git a/src/Core/Components/Dialog/MessageBox/DialogService.cs b/src/Core/Components/Dialog/MessageBox/DialogService.cs new file mode 100644 index 0000000000..2cc6808347 --- /dev/null +++ b/src/Core/Components/Dialog/MessageBox/DialogService.cs @@ -0,0 +1,101 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Dialog.MessageBox; +using Microsoft.FluentUI.AspNetCore.Components.Localization; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public partial class DialogService : IDialogService +{ + /// + public Task ShowSuccessAsync(string message, string? title = null, string? button = null) + { + return ShowMessageBoxAsync(new MessageBoxOptions + { + Title = title ?? Localizer[LanguageResource.MessageBox_Success], + Message = message, + PrimaryButton = button ?? Localizer[LanguageResource.MessageBox_ButtonOk], + PrimaryShortCut = "Enter;Escape", + Icon = new CoreIcons.Filled.Size20.CheckmarkCircle(), + IconColor = Color.Success, + }); + } + + /// + public Task ShowWarningAsync(string message, string? title = null, string? button = null) + { + return ShowMessageBoxAsync(new MessageBoxOptions + { + Title = title ?? Localizer[LanguageResource.MessageBox_Warning], + Message = message, + PrimaryButton = button ?? Localizer[LanguageResource.MessageBox_ButtonOk], + PrimaryShortCut = "Enter;Escape", + Icon = new CoreIcons.Filled.Size20.Warning(), + IconColor = Color.Warning, + }); + } + + /// + public Task ShowErrorAsync(string message, string? title = null, string? button = null) + { + return ShowMessageBoxAsync(new MessageBoxOptions + { + Title = title ?? Localizer[LanguageResource.MessageBox_Error], + Message = message, + PrimaryButton = button ?? Localizer[LanguageResource.MessageBox_ButtonOk], + PrimaryShortCut = "Enter;Escape", + Icon = new CoreIcons.Filled.Size20.DismissCircle(), + IconColor = Color.Error, + }); + } + + /// + public Task ShowInfoAsync(string message, string? title = null, string? button = null) + { + return ShowMessageBoxAsync(new MessageBoxOptions + { + Title = title ?? Localizer[LanguageResource.MessageBox_Information], + Message = message, + PrimaryButton = button ?? Localizer[LanguageResource.MessageBox_ButtonOk], + PrimaryShortCut = "Enter;Escape", + Icon = new CoreIcons.Filled.Size20.Info(), + IconColor = Color.Info, + }); + } + + /// + public Task ShowConfirmationAsync(string message, string? title = null, string? primaryButton = null, string? secondaryButton = null) + { + return ShowMessageBoxAsync(new MessageBoxOptions + { + Title = title ?? Localizer[LanguageResource.MessageBox_Confirmation], + Message = message, + PrimaryButton = primaryButton ?? Localizer[LanguageResource.MessageBox_ButtonYes], + PrimaryShortCut = "Enter;Y", + SecondaryButton = secondaryButton ?? Localizer[LanguageResource.MessageBox_ButtonNo], + SecondaryShortCut = "Escape;N", + Icon = new CoreIcons.Regular.Size20.QuestionCircle(), + IconColor = Color.Default, + }); + } + + /// /> + public Task ShowMessageBoxAsync(MessageBoxOptions options) + { + return ShowDialogAsync(config => + { + config.Header.Title = options.Title; + config.Footer.PrimaryAction.Label = options.PrimaryButton; + config.Footer.PrimaryAction.ShortCut = options.PrimaryShortCut; + config.Footer.SecondaryAction.Label = options.SecondaryButton; + config.Footer.SecondaryAction.ShortCut = options.SecondaryShortCut; + config.Parameters.Add(nameof(FluentMessageBox.Message), new MarkupString(options.Message ?? "")); + config.Parameters.Add(nameof(FluentMessageBox.Icon), options.Icon); + config.Parameters.Add(nameof(FluentMessageBox.IconColor), options.IconColor); + }); + } +} diff --git a/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor b/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor new file mode 100644 index 0000000000..c77dd4bdfc --- /dev/null +++ b/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor @@ -0,0 +1,13 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components.Dialog.MessageBox + + +
+ @if (Icon != null) + { + + } +
+ @Message +
+
+
diff --git a/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor.cs b/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor.cs new file mode 100644 index 0000000000..c4bd5f65f7 --- /dev/null +++ b/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components.Dialog.MessageBox; + +/// +/// Represents a message box dialog. +/// +public partial class FluentMessageBox +{ + /// + /// Gets or sets the content of the message box. + /// + [Parameter] + public MarkupString? Message { get; set; } + + /// + /// Gets or sets the icon of the message box. + /// + [Parameter] + public Icon Icon { get; set; } = new CoreIcons.Filled.Size20.CheckmarkCircle(); + + /// + /// Gets or sets the icon color. + /// + [Parameter] + public Color IconColor { get; set; } = Color.Success; +} diff --git a/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor.css b/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor.css new file mode 100644 index 0000000000..f3372fbe28 --- /dev/null +++ b/src/Core/Components/Dialog/MessageBox/FluentMessageBox.razor.css @@ -0,0 +1,16 @@ +.fluent-message-box { + display: flex; + margin-top: 10px; + justify-content: start; + align-items: center; + column-gap: 10px; + width: 100%; +} + + .fluent-message-box .icon { + min-width: 32px; + } + + .fluent-message-box .message { + + } diff --git a/src/Core/Components/Dialog/MessageBox/IDialogService.cs b/src/Core/Components/Dialog/MessageBox/IDialogService.cs new file mode 100644 index 0000000000..ea751f5003 --- /dev/null +++ b/src/Core/Components/Dialog/MessageBox/IDialogService.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public partial interface IDialogService +{ + + /// + /// Shows a dialog with a success (green) icon, a message and an OK button. + /// + /// Message to display in the dialog. + /// Title to display in the dialog header. + /// Text to display in the primary action button. + /// Result of the dialog. Always `Cancelled = false`. + Task ShowSuccessAsync(string message, string? title = null, string? button = null); + + /// + /// Shows a dialog with a warning (orange) icon, a message and an OK button. + /// + /// Message to display in the dialog. + /// Title to display in the dialog header. Default is "Success". + /// Text to display in the primary action button. Default is "OK". + /// Result of the dialog. Always `Cancelled = false`. + Task ShowWarningAsync(string message, string? title = null, string? button = null); + + /// + /// Shows a dialog with an error (red) icon, a message and an OK button. + /// + /// Message to display in the dialog. + /// Title to display in the dialog header. Default is "Error". + /// Text to display in the primary action button. Default is "OK". + /// Result of the dialog. Always `Cancelled = false`. + Task ShowErrorAsync(string message, string? title = null, string? button = null); + + /// + /// Shows a dialog with an information (gray) icon, a message and an OK button. + /// + /// Message to display in the dialog. + /// Title to display in the dialog header. Default is "Information". + /// Text to display in the primary action button. Default is "OK". + /// Result of the dialog. Always `Cancelled = false`. + Task ShowInfoAsync(string message, string? title = null, string? button = null); + + /// + /// Shows a dialog with a confirmation icon, a message and a Yes/No buttons. + /// + /// Message to display in the dialog. + /// Title to display in the dialog header. Default is "Confirmation". + /// Text to display in the primary action button. Default is "Yes". + /// Text to display in the secondary action button. Default is "No". + /// Result of the dialog: Yes returns `Cancelled = false`, No returns `Cancelled = true` + Task ShowConfirmationAsync(string message, string? title = null, string? primaryButton = null, string? secondaryButton = null); + + /// + /// Shows a dialog with the specified options. + /// + /// Options to configure the dialog. + /// + Task ShowMessageBoxAsync(MessageBoxOptions options); +} diff --git a/src/Core/Components/Dialog/MessageBox/MessageBoxOptions.cs b/src/Core/Components/Dialog/MessageBox/MessageBoxOptions.cs new file mode 100644 index 0000000000..cdad26a076 --- /dev/null +++ b/src/Core/Components/Dialog/MessageBox/MessageBoxOptions.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for the message box dialog. +/// +public class MessageBoxOptions +{ + /// + /// Gets or sets the message to display in the dialog. + /// + public string? Message { get; set; } + + /// + /// Gets or sets the title to display in the dialog header. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the text to display in the primary action button. + /// + public string? PrimaryButton { get; set; } + + /// + /// Gets or sets the text to display in the secondary action button. + /// + public string? SecondaryButton { get; set; } + + /// + /// Gets or sets the text to display in the primary action button. + /// + public string? PrimaryShortCut { get; set; } = "Enter"; + + /// + /// Gets or sets the text to display in the secondary action button. + /// + public string? SecondaryShortCut { get; set; } = "Escape"; + + /// + /// Gets or sets the icon to display in the dialog. + /// + public Icon? Icon { get; set; } + + /// + /// Gets or sets the color of the icon. + /// + public Color IconColor { get; set; } = Color.Default; +} diff --git a/src/Core/Components/Dialog/Services/DialogInstance.cs b/src/Core/Components/Dialog/Services/DialogInstance.cs new file mode 100644 index 0000000000..1539de017c --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogInstance.cs @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a dialog instance used with the . +/// +public class DialogInstance : IDialogInstance +{ + private static long _counter; + private readonly Type _componentType; + internal readonly TaskCompletionSource ResultCompletion = new(); + + /// + internal DialogInstance(IDialogService dialogService, Type componentType, DialogOptions options) + { + _componentType = componentType; + Options = options; + DialogService = dialogService; + Id = string.IsNullOrEmpty(options.Id) ? Identifier.NewId() : options.Id; + Index = Interlocked.Increment(ref _counter); + } + + /// + Type IDialogInstance.ComponentType => _componentType; + + /// + internal IDialogService DialogService { get; } + + /// + internal FluentDialog? FluentDialog { get; set; } + + /// + public DialogOptions Options { get; internal set; } + + /// + public Task Result => ResultCompletion.Task; + + /// " + public string Id { get; } + + /// " + public long Index { get; } + + /// + public Task CancelAsync() + { + return DialogService.CloseAsync(this, DialogResult.Cancel()); + } + + /// + public Task CloseAsync() + { + return DialogService.CloseAsync(this, DialogResult.Ok()); + } + + /// + public Task CloseAsync(T result) + { + return DialogService.CloseAsync(this, DialogResult.Ok(result)); + } + + /// + public Task CloseAsync(DialogResult result) + { + return DialogService.CloseAsync(this, result); + } +} diff --git a/src/Core/Components/Dialog/Services/DialogOptions.cs b/src/Core/Components/Dialog/Services/DialogOptions.cs new file mode 100644 index 0000000000..07fe8e4a86 --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogOptions.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for configuring a dialog. +/// +public class DialogOptions : IFluentComponentBase +{ + /// + /// Initializes a new instance of the class. + /// + public DialogOptions() + { + } + + /// + /// Initializes a new instance of the class + /// using the specified implementation factory. + /// + /// + public DialogOptions(Action implementationFactory) + { + implementationFactory.Invoke(this); + } + + /// + public string? Id { get; set; } + + /// + public string? Class { get; set; } + + /// + public string? Style { get; set; } + + /// + public object? Data { get; set; } + + /// + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + /// Gets the header title of the dialog. + /// + public DialogOptionsHeader Header { get; } = new(); + + /// + /// Gets the footer actions of the dialog. + /// + public DialogOptionsFooter Footer { get; } = new(); + + /// + /// Gets or sets the dialog alignment. + /// + public DialogAlignment Alignment { get; set; } = DialogAlignment.Default; + + /// + /// Gets or sets the width of the dialog. Must be a valid CSS width value like '600px' or '3em' + /// Only used if Alignment is set to . + /// + public string? Width { get; set; } + + /// + /// Gets or sets the height of the dialog. Must be a valid CSS height value like '600px' or '3em' + /// Only used if Alignment is set to . + /// + public string? Height { get; set; } + + /// + /// Gets a list of dialog parameters. + /// Each parameter must correspond to a `[Parameter]` property defined in the component. + /// + public IDictionary Parameters { get; set; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets or sets the action raised when the dialog is opened or closed. + /// + public Action? OnStateChange { get; set; } +} diff --git a/src/Core/Components/Dialog/Services/DialogOptionsFooter.cs b/src/Core/Components/Dialog/Services/DialogOptionsFooter.cs new file mode 100644 index 0000000000..fa1651bb2f --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogOptionsFooter.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for configuring a dialog footer. +/// +public class DialogOptionsFooter +{ + /// + public DialogOptionsFooter() + { + + } + + /// + /// Gets or sets the primary action for the footer. + /// + public DialogOptionsFooterAction PrimaryAction { get; } = new(ButtonAppearance.Primary); + + /// + /// Gets or sets the secondary action for the footer. + /// + public DialogOptionsFooterAction SecondaryAction { get; } = new(ButtonAppearance.Default); + + /// + internal IEnumerable Actions => [PrimaryAction, SecondaryAction]; + + /// + internal bool HasActions => PrimaryAction.ToDisplay || SecondaryAction.ToDisplay; +} diff --git a/src/Core/Components/Dialog/Services/DialogOptionsFooterAction.cs b/src/Core/Components/Dialog/Services/DialogOptionsFooterAction.cs new file mode 100644 index 0000000000..9dac54b7c2 --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogOptionsFooterAction.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Web; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for configuring a dialog footer action button. +/// +public class DialogOptionsFooterAction +{ + /// + internal DialogOptionsFooterAction(ButtonAppearance appearance) + { + Appearance = appearance; + ShortCut = appearance == ButtonAppearance.Primary ? "Enter" : "Escape"; + } + + /// + internal ButtonAppearance Appearance { get; } + + /// + /// Gets or sets the label of the action button. + /// By default, this label is not set. So the button will not be displayed. + /// + public string? Label { get; set; } + + /// + /// Gets or sets the shortcut key for the action button. + /// By default, "Enter" for the primary action and "Escape" for the secondary action. + /// + /// + /// The shortcut key is a combination of one or more keys separated by a plus sign. + /// You must use the key names defined in the class. + /// You can use the following modifier keys: "Ctrl", "Alt", "Shift", in this order. + /// + /// + /// "Enter", "Escape", "Ctrl+Enter", "Ctrl+Alt+Shift+Enter", "Escape;Enter". + /// + public string? ShortCut { get; set; } + + /// + /// Gets or sets whether the action button is visible. + /// + public bool Visible { get; set; } = true; + + /// + /// Gets or sets whether the action button is disabled. + /// + public bool Disabled { get; set; } + + /// + /// Gets or sets the action to be performed when the action button is clicked. + /// + public Func? OnClickAsync { get; set; } + + /// + internal bool ToDisplay => !string.IsNullOrEmpty(Label) && Visible; +} diff --git a/src/Core/Components/Dialog/Services/DialogOptionsHeader.cs b/src/Core/Components/Dialog/Services/DialogOptionsHeader.cs new file mode 100644 index 0000000000..b1a36783cd --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogOptionsHeader.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for configuring a dialog header. +/// +public class DialogOptionsHeader +{ + /// + internal DialogOptionsHeader() + { + + } + + /// + /// Gets or sets the title of the dialog. + /// + public string? Title { get; set; } +} diff --git a/src/Core/Components/Dialog/Services/DialogResult.cs b/src/Core/Components/Dialog/Services/DialogResult.cs new file mode 100644 index 0000000000..e1675a0e68 --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogResult.cs @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents the result of a dialog. +/// +public class DialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// + protected internal DialogResult(TContent? content, bool cancelled) + { + Value = content; + Cancelled = cancelled; + } + + /// + /// Gets the content of the dialog result. + /// + public TContent? Value { get; } + + /// + /// Gets a value indicating whether the dialog was cancelled. + /// + public bool Cancelled { get; } + + /// + /// Creates a dialog result with the specified content. + /// + /// Type of the content. + /// The content of the dialog result. + /// The dialog result. + public static DialogResult Ok(T result) => new(result, cancelled: false); + + /// + /// Creates a dialog result with the specified content. + /// + /// The dialog result. + public static DialogResult Ok() => Ok(result: null); + + /// + /// Creates a dialog result with the specified content. + /// + /// The content of the dialog result. + /// The dialog result. + public static DialogResult Cancel(T result) => new(result, cancelled: true); + + /// + /// Creates a dialog result with the specified content. + /// + /// The dialog result. + public static DialogResult Cancel() => Cancel(result: null); +} + +/// +/// Represents the result of a dialog. +/// +public class DialogResult : DialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// + protected internal DialogResult(object? content, bool cancelled) : base(content, cancelled) + { + } + + /// + /// Gets the content of the dialog result. + /// + /// + /// + public T GetValue() + { + if (Value is T variable) + { + return variable; + } + + return default(T)!; + } +} diff --git a/src/Core/Components/Dialog/Services/DialogService.cs b/src/Core/Components/Dialog/Services/DialogService.cs new file mode 100644 index 0000000000..a21ece7cdf --- /dev/null +++ b/src/Core/Components/Dialog/Services/DialogService.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Service for showing dialogs. +/// +public partial class DialogService : FluentServiceBase, IDialogService +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// List of services available in the application. + /// Localizer for the application. + public DialogService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) + { + _serviceProvider = serviceProvider; + Localizer = localizer ?? FluentLocalizerInternal.Default; + } + + /// + protected IFluentLocalizer Localizer { get; } + + /// + public async Task CloseAsync(IDialogInstance dialog, DialogResult result) + { + var dialogInstance = dialog as DialogInstance; + + // Raise the DialogState.Closing event + dialogInstance?.FluentDialog?.RaiseOnStateChangeAsync(dialog, DialogState.Closing); + + // Remove the dialog from the DialogProvider + await RemoveDialogFromProviderAsync(dialog); + + // Set the result of the dialog + dialogInstance?.ResultCompletion.TrySetResult(result); + + // Raise the DialogState.Closed event + dialogInstance?.FluentDialog?.RaiseOnStateChangeAsync(dialog, DialogState.Closed); + } + + /// + public virtual async Task ShowDialogAsync(Type componentType, DialogOptions options) + { + if (!componentType.IsSubclassOf(typeof(ComponentBase))) + { + throw new ArgumentException($"{componentType.FullName} must be a Blazor Component", nameof(componentType)); + } + + if (this.ProviderNotAvailable()) + { + throw new FluentServiceProviderException(); + } + + var instance = new DialogInstance(this, componentType, options); + + // Add the dialog to the service, and render it. + ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentDialog.")); + await ServiceProvider.OnUpdatedAsync.Invoke(instance); + + return await instance.Result; + } + + /// + public Task ShowDialogAsync(DialogOptions options) where TDialog : ComponentBase + { + return ShowDialogAsync(typeof(TDialog), options); + } + + /// + public Task ShowDialogAsync(Action options) where TDialog : ComponentBase + { + return ShowDialogAsync(typeof(TDialog), new DialogOptions(options)); + } + + /// + /// Removes the dialog from the DialogProvider. + /// + /// + /// + /// + internal Task RemoveDialogFromProviderAsync(IDialogInstance? dialog) + { + if (dialog is null) + { + return Task.CompletedTask; + } + + // Remove the HTML code from the DialogProvider + if (!ServiceProvider.Items.TryRemove(dialog.Id, out _)) + { + throw new InvalidOperationException($"Failed to remove dialog from DialogProvider: the ID '{dialog.Id}' doesn't exist in the DialogServiceProvider."); + } + + return ServiceProvider.OnUpdatedAsync.Invoke(dialog); + } +} diff --git a/src/Core/Components/Dialog/Services/IDialogInstance.cs b/src/Core/Components/Dialog/Services/IDialogInstance.cs new file mode 100644 index 0000000000..e93f01b053 --- /dev/null +++ b/src/Core/Components/Dialog/Services/IDialogInstance.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for DialogReference +/// +public interface IDialogInstance +{ + /// + /// Gets the component type of the dialog. + /// + internal Type ComponentType { get; } + + /// + /// Gets the unique identifier for the dialog. + /// If this value is not set in the , a new identifier is generated. + /// + string Id { get; } + + /// + /// Gets the index of the dialog (sequential number). + /// + long Index { get; } + + /// + /// Gets the options used to configure the dialog. + /// + DialogOptions Options { get; } + + /// + /// Gets the result of the dialog. + /// + Task Result { get; } + + /// + /// Closes the dialog with a Cancel result. + /// + /// + Task CancelAsync(); + + /// + /// Closes the dialog with the specified result. + /// + /// + Task CloseAsync(); + + /// + /// Closes the dialog with the specified result. + /// + /// Result to close the dialog with. + /// + Task CloseAsync(DialogResult result); + + /// + /// Closes the dialog with the specified result. + /// + /// Result to close the dialog with. + /// + Task CloseAsync(T result); +} diff --git a/src/Core/Components/Dialog/Services/IDialogService.cs b/src/Core/Components/Dialog/Services/IDialogService.cs new file mode 100644 index 0000000000..d2aa8dae5b --- /dev/null +++ b/src/Core/Components/Dialog/Services/IDialogService.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for DialogService +/// +public partial interface IDialogService : IFluentServiceBase +{ + /// + /// Closes the dialog with the specified result. + /// + /// Instance of the dialog to close. + /// Result of closing the dialog box. + /// + Task CloseAsync(IDialogInstance dialog, DialogResult result); + + /// + /// Shows a dialog with the component type as the body, + /// + /// Type of component to display. + /// Options to configure the dialog component. + Task ShowDialogAsync(Type dialogComponent, DialogOptions options); + + /// + /// Shows a dialog with the component type as the body. + /// + /// Type of component to display. + /// Options to configure the dialog component. + Task ShowDialogAsync(DialogOptions options) + where TDialog : ComponentBase; + + /// + /// Shows a dialog with the component type as the body. + /// + /// Type of component to display. + /// Options to configure the dialog component. + Task ShowDialogAsync(Action options) + where TDialog : ComponentBase; +} diff --git a/src/Core/Components/Grid/FluentGridItem.razor.cs b/src/Core/Components/Grid/FluentGridItem.razor.cs index d15ab67c4b..3c388b4c85 100644 --- a/src/Core/Components/Grid/FluentGridItem.razor.cs +++ b/src/Core/Components/Grid/FluentGridItem.razor.cs @@ -9,7 +9,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Content placed within a layout using the component. +/// Value placed within a layout using the component. /// public partial class FluentGridItem : FluentComponentBase { diff --git a/src/Core/Components/Icons/CoreIcons.cs b/src/Core/Components/Icons/CoreIcons.cs index 57e03a2ec2..2fc651a871 100644 --- a/src/Core/Components/Icons/CoreIcons.cs +++ b/src/Core/Components/Icons/CoreIcons.cs @@ -27,6 +27,10 @@ internal static partial class Regular internal static partial class Size20 { public class LineHorizontal3 : Icon { public LineHorizontal3() : base("LineHorizontal3", IconVariant.Regular, IconSize.Size20, "") { } }; + + public class QuestionCircle : Icon { public QuestionCircle() : base("QuestionCircle", IconVariant.Regular, IconSize.Size20, "") { } }; + + public class Dismiss : Icon { public Dismiss() : base("Dismiss", IconVariant.Regular, IconSize.Size20, "") { } }; } } @@ -38,6 +42,13 @@ internal static partial class Filled [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static partial class Size20 { + public class CheckmarkCircle : Icon { public CheckmarkCircle() : base("CheckmarkCircle", IconVariant.Filled, IconSize.Size20, "") { } } + + public class Info : Icon { public Info() : base("Info", IconVariant.Filled, IconSize.Size20, "") { } } + + public class Warning : Icon { public Warning() : base("Warning", IconVariant.Filled, IconSize.Size20, "") { } } + + public class DismissCircle : Icon { public DismissCircle() : base("WaDismissCirclerning", IconVariant.Filled, IconSize.Size20, "") { } } } } } diff --git a/src/Core/Components/Layout/FluentLayout.razor.cs b/src/Core/Components/Layout/FluentLayout.razor.cs index 7c25dacb37..e9c0d258de 100644 --- a/src/Core/Components/Layout/FluentLayout.razor.cs +++ b/src/Core/Components/Layout/FluentLayout.razor.cs @@ -8,8 +8,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Component that defines a layout for a page, using a grid composed of a Header, a Footer and 3 columns: Menu, Content and Aside Pane. -/// For mobile devices (< 768px), the layout is a single column with the Menu, Content and Footer panes stacked vertically. +/// Component that defines a layout for a page, using a grid composed of a Header, a Footer and 3 columns: Menu, Value and Aside Pane. +/// For mobile devices (< 768px), the layout is a single column with the Menu, Value and Footer panes stacked vertically. /// public partial class FluentLayout { diff --git a/src/Core/Enums/DialogAlignment.cs b/src/Core/Enums/DialogAlignment.cs new file mode 100644 index 0000000000..dc547353d1 --- /dev/null +++ b/src/Core/Enums/DialogAlignment.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The alignment of the dialog. +/// +public enum DialogAlignment +{ + /// + /// Default alignment (center). + /// + Default, + + /// + /// Panel of the left. + /// + Start, + + /// + /// Panel of the right. + /// + End, +} diff --git a/src/Core/Enums/DialogState.cs b/src/Core/Enums/DialogState.cs new file mode 100644 index 0000000000..d8ae8b02aa --- /dev/null +++ b/src/Core/Enums/DialogState.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents the state of a dialog. +/// +public enum DialogState +{ + /// + /// The dialog is hidden. + /// + Closed, + + /// + /// The dialog is showing. + /// + Opening, + + /// + /// The dialog is shown. + /// + Open, + + /// + /// The dialog is closing. + /// + Closing, +} diff --git a/src/Core/Events/DialogToggleEventArgs.cs b/src/Core/Events/DialogToggleEventArgs.cs new file mode 100644 index 0000000000..907a85558c --- /dev/null +++ b/src/Core/Events/DialogToggleEventArgs.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Event arguments for the FluentDialog toggle event. +/// +internal class DialogToggleEventArgs : EventArgs +{ + /// + /// Gets or sets the ID of the dialog. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the old state of the dialog. + /// + public string? OldState { get; set; } + + /// + /// Gets or sets the new state of the dialog. + /// + public string? NewState { get; set; } + + /// + /// Gets or sets the type of the dialog. + /// + public string? Type { get; set; } +} diff --git a/src/Core/Events/EventHandlers.cs b/src/Core/Events/EventHandlers.cs new file mode 100644 index 0000000000..78411b02c6 --- /dev/null +++ b/src/Core/Events/EventHandlers.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/* Blazor supports custom event arguments, which enable you to pass arbitrary data to .NET event handlers with custom events. + * https://learn.microsoft.com/aspnet/core/blazor/components/event-handling#custom-event-arguments + * + * In the Components C# project + * ---------------------------- + * 1. In this `Events` folder, create a class that derives from `EventArgs`. Ex. DialogToggleEventArgs.cs + * 2. Add the `EventHandler` attribute (prefixed by "on") to the `EventHandlers` class in this file. Ex. "ondialogtoggle" + * + * In the Components.Scripts project + * --------------------------------- + * 3. Define a registering method the Custom Event Type in the `FluentUICustomEvents.ts` file. Ex. "dialogtoggle" + * 4. Call the Registering method in the `Startup.ts` file. + * + * In the C# component + * ------------------- + * 5. Use this new event in the component: `@ondialogtoggle="@(e => ...)"` + */ + +/// +/// List of custom events to associate an event argument type with an event attribute name. +/// +[EventHandler("ondialogbeforetoggle", typeof(DialogToggleEventArgs), enableStopPropagation: true, enablePreventDefault: true)] +[EventHandler("ondialogtoggle", typeof(DialogToggleEventArgs), enableStopPropagation: true, enablePreventDefault: true)] +public static class EventHandlers +{ +} diff --git a/src/Core/Extensions/ServiceCollectionExtensions.cs b/src/Core/Extensions/ServiceCollectionExtensions.cs index 4074f1536c..ed9c9734f7 100644 --- a/src/Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Extensions/ServiceCollectionExtensions.cs @@ -19,22 +19,18 @@ public static class ServiceCollectionExtensions /// Library configuration public static IServiceCollection AddFluentUIComponents(this IServiceCollection services, LibraryConfiguration? configuration = null) { - /* - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - var options = configuration ?? new(); - if (options.UseTooltipServiceProvider) - { - services.AddScoped(); - } - services.AddSingleton(options); - */ - - services.AddScoped(provider => configuration?.Localizer ?? FluentLocalizerInternal.Default); + var options = configuration ?? new(); + + var serviceLifetime = options?.ServiceLifetime ?? ServiceLifetime.Scoped; + if (serviceLifetime == ServiceLifetime.Transient) + { + throw new NotSupportedException("Transient lifetime is not supported for Fluent UI services."); + } + + // Add services + services.Add(provider => options ?? new(), serviceLifetime); + services.Add(serviceLifetime); + services.Add(provider => options?.Localizer ?? FluentLocalizerInternal.Default, serviceLifetime); return services; } @@ -51,4 +47,29 @@ public static IServiceCollection AddFluentUIComponents(this IServiceCollection s return AddFluentUIComponents(services, options); } + + /// + private static IServiceCollection Add(this IServiceCollection services, Func implementationFactory, ServiceLifetime lifetime) + where TService : class + { + return lifetime switch + { + ServiceLifetime.Singleton => services.AddSingleton(implementationFactory), + ServiceLifetime.Scoped => services.AddScoped(implementationFactory), + _ => throw new NotSupportedException($"Service lifetime {lifetime} is not supported.") + }; + } + + /// + private static IServiceCollection Add(this IServiceCollection services, ServiceLifetime lifetime) + where TService : class + where TImplementation : class, TService + { + return lifetime switch + { + ServiceLifetime.Singleton => services.AddSingleton(), + ServiceLifetime.Scoped => services.AddScoped(), + _ => throw new NotSupportedException($"Service lifetime {lifetime} is not supported.") + }; + } } diff --git a/src/Core/Extensions/ServiceProviderExtensions.cs b/src/Core/Extensions/ServiceProviderExtensions.cs new file mode 100644 index 0000000000..9959b78187 --- /dev/null +++ b/src/Core/Extensions/ServiceProviderExtensions.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +internal static class ServiceProviderExtensions +{ + /// + /// Gets a value indicating whether the provider was added by the user and is available. + /// + public static bool ProviderNotAvailable(this IFluentServiceBase provider) + { + return string.IsNullOrEmpty(provider.ProviderId); + } +} diff --git a/src/Core/Infrastructure/LibraryConfiguration.cs b/src/Core/Infrastructure/LibraryConfiguration.cs index 236917f0f9..ab5a7e93a1 100644 --- a/src/Core/Infrastructure/LibraryConfiguration.cs +++ b/src/Core/Infrastructure/LibraryConfiguration.cs @@ -2,6 +2,8 @@ // MIT License - Copyright (c) Microsoft Corporation. All rights reserved. // ------------------------------------------------------------------------ +using Microsoft.Extensions.DependencyInjection; + namespace Microsoft.FluentUI.AspNetCore.Components; /// @@ -21,6 +23,13 @@ public class LibraryConfiguration /// public bool UseTooltipServiceProvider { get; set; } = true; + /// + /// Gets or sets the service lifetime for the library services, when using Fluent UI in WebAssembly, it can make sense to use . + /// Default is . + /// Only and are supported. + /// + public ServiceLifetime ServiceLifetime { get; set; } = ServiceLifetime.Scoped; + /// /// Gets or sets the FluentLocalizer instance used to localize the library components. /// diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index f514534fbe..14daae0ecc 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -126,4 +126,31 @@ Required + + Yes + + + OK + + + No + + + Success + + + Warning + + + Error + + + Information + + + Confirmation + + + Cancel + \ No newline at end of file diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 4f5586ddfe..3bf4c01949 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -109,6 +109,7 @@ false true true + CS1591 diff --git a/src/Core/Utilities/StyleBuilder.cs b/src/Core/Utilities/StyleBuilder.cs index 89f8c4181a..64ecd59138 100644 --- a/src/Core/Utilities/StyleBuilder.cs +++ b/src/Core/Utilities/StyleBuilder.cs @@ -49,7 +49,7 @@ public StyleBuilder(string? userStyles) /// /// Style to add /// StyleBuilder - public StyleBuilder AddStyle(string prop, string? value) => AddRaw($"{prop}: {value}"); + public StyleBuilder AddStyle(string prop, string? value) => string.IsNullOrEmpty(value) ? this : AddRaw($"{prop}: {value}"); /// /// Adds a conditional in-line style to the builder with space separator and closing semicolon.. diff --git a/src/Core/Utilities/ZIndex.cs b/src/Core/Utilities/ZIndex.cs new file mode 100644 index 0000000000..edcd96ed93 --- /dev/null +++ b/src/Core/Utilities/ZIndex.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// ZIndex values for FluentUI components +/// +public static class ZIndex +{ + /// + /// ZIndex for the component. + /// + public static int Dialog { get; set; } = 999; +} diff --git a/tests/Core/Components/Dialog/Data/ShortcutData.cs b/tests/Core/Components/Dialog/Data/ShortcutData.cs new file mode 100644 index 0000000000..48765bcd32 --- /dev/null +++ b/tests/Core/Components/Dialog/Data/ShortcutData.cs @@ -0,0 +1,98 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Collections; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Dialog.Templates; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Dialog.Data; + +public class ShortcutData : IEnumerable +{ + public IEnumerator GetEnumerator() + { + // Primary Action + yield return [new ShortcutDataItem(ExpectedCancelled: false, + Pressed: ToPress("Enter"))]; + + yield return [new ShortcutDataItem(ExpectedCancelled: false, + Pressed: ToPress("Enter"), + PrimaryClickAsync: (e) => e.CloseAsync() )]; + + yield return [new ShortcutDataItem(ExpectedCancelled: false, + PrimaryShortcut: "Enter", + Pressed: ToPress("Enter"))]; + + yield return [new ShortcutDataItem(ExpectedCancelled: false, + PrimaryShortcut: "Enter;Ctrl+Enter", + Pressed: ToPress("Enter", ctrlKey: true))]; + + yield return [new ShortcutDataItem(ExpectedCancelled: false, + PrimaryShortcut: "Ctrl+Alt+Shift+Enter", + Pressed: ToPress("Enter", ctrlKey: true, shiftKey: true, altKey: true))]; + + // Secondary Action + yield return [new ShortcutDataItem(ExpectedCancelled: true, + Pressed: ToPress("Escape"))]; + + yield return [new ShortcutDataItem(ExpectedCancelled: true, + Pressed: ToPress("Escape"), + SecondaryClickAsync: (e) => e.CancelAsync() )]; + + yield return [new ShortcutDataItem(ExpectedCancelled: true, + SecondaryShortcut: "Escape", + Pressed: ToPress("Escape"))]; + + yield return [new ShortcutDataItem(ExpectedCancelled: true, + SecondaryShortcut: "Escape;Ctrl+Escape", + Pressed: ToPress("Escape", ctrlKey: true))]; + + yield return [new ShortcutDataItem(ExpectedCancelled: true, + SecondaryShortcut: "Ctrl+Alt+Shift+Escape", + Pressed: ToPress("Escape", ctrlKey: true, shiftKey: true, altKey: true))]; + + // Unknown Shortcut + yield return [new ShortcutDataItem(ExpectedCancelled: false, + Pressed: ToPress("A"), + RenderOptions: CloseAfter200ms)]; + + yield return [new ShortcutDataItem(ExpectedCancelled: false, + PrimaryShortcut: "", + Pressed: ToPress("A"), + RenderOptions: CloseAfter200ms)]; + + yield return [new ShortcutDataItem(ExpectedCancelled: false, + SecondaryShortcut: "", + Pressed: ToPress("A"), + RenderOptions: CloseAfter200ms)]; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private static DialogRenderOptions CloseAfter200ms => new() + { + AutoClose = true, + AutoCloseDelay = 200 + }; + + private static KeyboardEventArgs ToPress(string key, bool? ctrlKey = null, bool? shiftKey = null, bool? altKey = null) + { + return new KeyboardEventArgs() + { + Key = key, + CtrlKey = ctrlKey ?? false, + ShiftKey = shiftKey ?? false, + AltKey = altKey ?? false + }; + } +} + +public record ShortcutDataItem( + bool ExpectedCancelled, + string? PrimaryShortcut = null, + Func? PrimaryClickAsync = null, + string? SecondaryShortcut = null, + Func? SecondaryClickAsync = null, + KeyboardEventArgs? Pressed = null, + DialogRenderOptions? RenderOptions = null); diff --git a/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Default.verified.razor.html b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Default.verified.razor.html new file mode 100644 index 0000000000..69522cd4cd --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Default.verified.razor.html @@ -0,0 +1,9 @@ + + +
My title
+
My title actions
+
My content
+
+ My actions +
+
diff --git a/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Disabled_Invisible.verified.razor.html b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Disabled_Invisible.verified.razor.html new file mode 100644 index 0000000000..c23ae19d25 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Disabled_Invisible.verified.razor.html @@ -0,0 +1,8 @@ + + +
My title
+
My content
+
+ OK +
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Disabled_Visible.verified.razor.html b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Disabled_Visible.verified.razor.html new file mode 100644 index 0000000000..6e0a4e63d8 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Disabled_Visible.verified.razor.html @@ -0,0 +1,9 @@ + + +
My title
+
My content
+
+ OK + Cancel +
+
diff --git a/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Enabled_Visible.verified.razor.html b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Enabled_Visible.verified.razor.html new file mode 100644 index 0000000000..6e6ebb3955 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogBodyTests.FluentDialogBody_Instance-Enabled_Visible.verified.razor.html @@ -0,0 +1,9 @@ + + +
My title
+
My content
+
+ OK + Cancel +
+
diff --git a/tests/Core/Components/Dialog/FluentDialogBodyTests.razor b/tests/Core/Components/Dialog/FluentDialogBodyTests.razor new file mode 100644 index 0000000000..a254a81812 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogBodyTests.razor @@ -0,0 +1,86 @@ +@using Xunit; +@inherits TestContext +@code +{ + public FluentDialogBodyTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + + DialogService = Services.GetRequiredService(); + DialogProvider = RenderComponent(); + } + + /// + /// Gets the dialog service. + /// + public IDialogService DialogService { get; } + + /// + /// Gets the dialog provider. + /// + public IRenderedComponent DialogProvider { get; } + + [Fact] + public void FluentDialogBody_Default() + { + var cut = Render( + @ + My title + My title actions + My content + + My actions + + + ); + + // Assert + cut.Verify(); + } + + [Theory] + [InlineData("Enabled_Visible", false, true)] + [InlineData("Disabled_Visible", true, true)] + [InlineData("Disabled_Invisible", true, false)] + public async Task FluentDialogBody_Instance(string name, bool buttonDisabled, bool buttonVisible) + { + bool buttonClicked = false; + + // Arrange + var options = new DialogOptions(); + var instance = new DialogInstance(DialogService, typeof(Templates.DialogRender), options); + + options.Header.Title = "My title"; + + options.Footer.PrimaryAction.Label = "OK"; + options.Footer.PrimaryAction.Disabled = false; + + options.Footer.SecondaryAction.Label = "Cancel"; + options.Footer.SecondaryAction.Disabled = buttonDisabled; + options.Footer.SecondaryAction.Visible = buttonVisible; + options.Footer.SecondaryAction.OnClickAsync = async (e) => + { + buttonClicked = true; + await Task.CompletedTask; + }; + + // Act + var cut = Render( + @ + My content + + ); + + var body = cut.FindComponent(); + await body.Instance.ActionClickHandlerAsync(options.Footer.SecondaryAction); + + // Assert + cut.Verify(suffix: name); + + if (buttonVisible && !buttonDisabled) + { + Assert.True(buttonClicked); + } + } +} diff --git a/tests/Core/Components/Dialog/FluentDialogTests.FluentDialog_Render.verified.razor.html b/tests/Core/Components/Dialog/FluentDialogTests.FluentDialog_Render.verified.razor.html new file mode 100644 index 0000000000..d7c8e2b55d --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogTests.FluentDialog_Render.verified.razor.html @@ -0,0 +1,9 @@ + +
+ + +
+ Hello John
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentDialogTests.FluentDialog_WithInstance.verified.razor.html b/tests/Core/Components/Dialog/FluentDialogTests.FluentDialog_WithInstance.verified.razor.html new file mode 100644 index 0000000000..73107f1fb1 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogTests.FluentDialog_WithInstance.verified.razor.html @@ -0,0 +1,14 @@ + +
+ + +
Dialog Title - John
+
+ Hello John
+
+ OK + Cancel +
+
+
+
diff --git a/tests/Core/Components/Dialog/FluentDialogTests.razor b/tests/Core/Components/Dialog/FluentDialogTests.razor new file mode 100644 index 0000000000..1037f2b5a7 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentDialogTests.razor @@ -0,0 +1,366 @@ +@using Xunit; +@inherits TestContext +@code +{ + // A timeout can be set when you open a dialog box and do not close it. + private const int TEST_TIMEOUT = 3000; + + public FluentDialogTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + + DialogService = Services.GetRequiredService(); + DialogProvider = RenderComponent(); + } + + /// + /// Gets the dialog service. + /// + public IDialogService DialogService { get; } + + /// + /// Gets the dialog provider. + /// + public IRenderedComponent DialogProvider { get; } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_Render() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Theory(Timeout = TEST_TIMEOUT)] + [InlineData("position=\"start\"", DialogAlignment.Start)] + [InlineData("position=\"end\"", DialogAlignment.End)] + public async Task FluentDialog_Panel(string expectedContains, DialogAlignment alignment) + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.Alignment = alignment; + + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + Assert.Contains(expectedContains, DialogProvider.Markup); + } + + [Theory(Timeout = TEST_TIMEOUT)] + [InlineData(DialogState.Closed, "unknown", "any-old", "any-new")] + [InlineData(DialogState.Open, "toggle", "any-old", "open")] + [InlineData(DialogState.Closed, "toggle", "any-old", "closed")] + [InlineData(DialogState.Opening, "beforetoggle", "closed", "any-new")] + [InlineData(DialogState.Closing, "beforetoggle", "open", "any-new")] + public async Task FluentDialog_Toggle(DialogState state, string eventType, string oldState, string newState) + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + var toggleArgs = new DialogToggleEventArgs() + { + Id = "my-id", + Type = eventType, + OldState = oldState, + NewState = newState, + }; + + DialogEventArgs? dialogEventArgs = null; + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.OnStateChange = (args) => + { + dialogEventArgs = args; + }; + + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Find the dialog and close it + var dialog = DialogProvider.FindComponent(); + await dialog.Instance.OnToggleAsync(toggleArgs); + + // Assert + Assert.NotNull(dialogEventArgs?.Instance); + Assert.Equal("my-id", dialogEventArgs?.Id); + Assert.Equal(state, dialogEventArgs?.State); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_OpenClose() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions() + { + AutoClose = true, + }; + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + }); + + // Wait for the dialog to be closed (auto-closed) + var result = await dialogTask; + + // Assert + Assert.Equal(1, renderOptions.OnInitializedCount); + Assert.Equal(1, renderOptions.OnParametersSetCount); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_ActionToClose() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + + var dialogOptions = new DialogOptions(); + dialogOptions.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + dialogOptions.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + dialogOptions.Footer.PrimaryAction.Label = "OK"; + dialogOptions.Footer.SecondaryAction.Label = "Cancel"; + + // Act + var dialogTask = DialogService.ShowDialogAsync(dialogOptions); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Click the primary action to close the dialog + DialogProvider.Find("fluent-button").Click(); + + // Assert + Assert.DoesNotContain("(options => + { + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + + options.Footer.PrimaryAction.Label = "OK"; + options.Footer.PrimaryAction.OnClickAsync = item.PrimaryClickAsync; + if (item.PrimaryShortcut is not null) + { + options.Footer.PrimaryAction.ShortCut = item.PrimaryShortcut; + } + + options.Footer.SecondaryAction.Label = "Cancel"; + options.Footer.SecondaryAction.OnClickAsync = item.SecondaryClickAsync; + if (item.SecondaryShortcut is not null) + { + options.Footer.SecondaryAction.ShortCut = item.SecondaryShortcut; + } + }); + + // Send a shortcut to close the dialog + if (item.Pressed is not null) + { + DialogProvider.Find("fluent-dialog").KeyDown(item.Pressed); + } + + var result = await dialogTask; + + // Assert + Assert.Equal(item.ExpectedCancelled, result.Cancelled); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_ShortcutInvalid() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions() + { + AutoClose = true, + AutoCloseDelay = 200, + AutoCloseResult = DialogResult.Ok("AUTO_CLOSED"), + }; + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + options.Footer.PrimaryAction.Label = "OK"; + options.Footer.SecondaryAction.Label = "Cancel"; + }); + + // Send a shortcut to close the dialog + DialogProvider.Find("fluent-dialog").KeyDown("A"); + + var result = await dialogTask; + + // Assert + Assert.False(result.Cancelled); + Assert.Equal("AUTO_CLOSED", result.Value); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_WithInstance() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_ComponentRule() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + + // Act + var ex = await Assert.ThrowsAsync(async () => + { + var dialogTask = await DialogService.ShowDialogAsync(typeof(string), new DialogOptions()); + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + Assert.Equal("System.String must be a Blazor Component (Parameter 'componentType')", ex.Message); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentDialog_ProviderRequired() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + DialogProvider.Instance.UpdateId(null); + + // Act + var ex = await Assert.ThrowsAsync>(async () => + { + var dialogTask = await DialogService.ShowDialogAsync(options => + { + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + }); + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + Assert.Equal("FluentDialogProvider needs to be added to the page/component hierarchy of your application/site. Usually this will be 'MainLayout' but depending on your setup it could be at a different location.", ex.Message); + } + + [Fact] + public void FluentDialog_DialogResult() + { + // Arrange + var result = new DialogResult(content: "OK", cancelled: false); + + // Assert + Assert.Equal("OK", result.Value); + Assert.Equal("OK", result.GetValue()); + Assert.Equal(0, result.GetValue()); + + Assert.False(result.Cancelled); + } + + [Fact] + public void FluentDialog_DialogResult_Ok() + { + // Arrange + var result = DialogResult.Ok("My content"); + + // Assert + Assert.Equal("My content", result.Value); + Assert.False(result.Cancelled); + } + + [Fact] + public void FluentDialog_DialogResult_Cancel() + { + // Arrange + var result = DialogResult.Cancel("My content"); + + // Assert + Assert.Equal("My content", result.Value); + Assert.True(result.Cancelled); + } + + [Fact] + public async Task FluentDialog_Instance() + { + // Arrange + var renderOptions = new Templates.DialogRenderOptions(); + + // Act + var dialogTask = DialogService.ShowDialogAsync(options => + { + options.Id = "my-dialog"; + options.Parameters.Add(nameof(Templates.DialogRender.Options), renderOptions); + options.Parameters.Add(nameof(Templates.DialogRender.Name), "John"); + options.AdditionalAttributes = new Dictionary { { "data-test", "my-dialog" } }; + }); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Find the dialog and close it + var dialog = DialogProvider.FindComponent(); + var instanceId = dialog.Instance.Instance?.Id; + var instanceIndex = dialog.Instance.Instance?.Index; + await dialog.Instance.Instance!.CloseAsync(42); + + // Wait for the dialog to be closed (auto-closed) + var result = await dialogTask; + + // Assert + Assert.Equal("my-dialog", instanceId); + Assert.Equal(42, result.Value); + Assert.True(instanceIndex > 0); + } +} diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Confirmation.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Confirmation.verified.razor.html new file mode 100644 index 0000000000..b7eadac5c0 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Confirmation.verified.razor.html @@ -0,0 +1,20 @@ + +
+ + +
Confirmation
+
+
+ +
My message
+
+
+
+ Yes + No +
+
+
+
diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_ConfirmationCustomized.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_ConfirmationCustomized.verified.razor.html new file mode 100644 index 0000000000..bf57dad8a1 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_ConfirmationCustomized.verified.razor.html @@ -0,0 +1,20 @@ + +
+ + +
My title
+
+
+ +
My message
+
+
+
+ Yes + No +
+
+
+
diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_EmptyOptions.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_EmptyOptions.verified.razor.html new file mode 100644 index 0000000000..528ee83bb0 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_EmptyOptions.verified.razor.html @@ -0,0 +1,12 @@ + +
+ + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Error.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Error.verified.razor.html new file mode 100644 index 0000000000..1e146d5855 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Error.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
Error
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_ErrorCustomized.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_ErrorCustomized.verified.razor.html new file mode 100644 index 0000000000..f9de5676af --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_ErrorCustomized.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
My title
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Info.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Info.verified.razor.html new file mode 100644 index 0000000000..a2877d01de --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Info.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
Information
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_InfoCustomized.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_InfoCustomized.verified.razor.html new file mode 100644 index 0000000000..1fc3ed61d7 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_InfoCustomized.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
My title
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Success.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Success.verified.razor.html new file mode 100644 index 0000000000..78261b1b6f --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Success.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
Success
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_SuccessCustomized.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_SuccessCustomized.verified.razor.html new file mode 100644 index 0000000000..746bbe0163 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_SuccessCustomized.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
My title
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Warning.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Warning.verified.razor.html new file mode 100644 index 0000000000..d5eb838ba2 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_Warning.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
Warning
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_WarningCustomized.verified.razor.html b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_WarningCustomized.verified.razor.html new file mode 100644 index 0000000000..c31a87d094 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.FluentMessageBox_WarningCustomized.verified.razor.html @@ -0,0 +1,19 @@ + +
+ + +
My title
+
+
+ +
My message
+
+
+
+ OK +
+
+
+
\ No newline at end of file diff --git a/tests/Core/Components/Dialog/FluentMessageBoxTests.razor b/tests/Core/Components/Dialog/FluentMessageBoxTests.razor new file mode 100644 index 0000000000..aebc2e0e27 --- /dev/null +++ b/tests/Core/Components/Dialog/FluentMessageBoxTests.razor @@ -0,0 +1,169 @@ +@using Xunit; +@inherits TestContext +@code +{ + // A timeout can be set when you open a dialog box and do not close it. + private const int TEST_TIMEOUT = 3000; + + public FluentMessageBoxTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + + DialogService = Services.GetRequiredService(); + DialogProvider = RenderComponent(); + } + + /// + /// Gets the dialog service. + /// + public IDialogService DialogService { get; } + + /// + /// Gets the dialog provider. + /// + public IRenderedComponent DialogProvider { get; } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_Success() + { + // Act + var dialogTask = DialogService.ShowSuccessAsync("My message"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_SuccessCustomized() + { + // Act + var dialogTask = DialogService.ShowSuccessAsync("My message", "My title", "OK"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_Info() + { + // Act + var dialogTask = DialogService.ShowInfoAsync("My message"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_InfoCustomized() + { + // Act + var dialogTask = DialogService.ShowInfoAsync("My message", "My title", "OK"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_Warning() + { + // Act + var dialogTask = DialogService.ShowWarningAsync("My message"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_WarningCustomized() + { + // Act + var dialogTask = DialogService.ShowWarningAsync("My message", "My title", "OK"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_Error() + { + // Act + var dialogTask = DialogService.ShowErrorAsync("My message"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_ErrorCustomized() + { + // Act + var dialogTask = DialogService.ShowErrorAsync("My message", "My title", "OK"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_Confirmation() + { + // Act + var dialogTask = DialogService.ShowConfirmationAsync("My message"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_ConfirmationCustomized() + { + // Act + var dialogTask = DialogService.ShowConfirmationAsync("My message", "My title", "Yes", "No"); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentMessageBox_EmptyOptions() + { + // Act + var dialogTask = DialogService.ShowMessageBoxAsync(new()); + + // Don't wait for the dialog to be closed + await Task.CompletedTask; + + // Assert + DialogProvider.Verify(); + } +} diff --git a/tests/Core/Components/Dialog/Templates/DialogRender.razor b/tests/Core/Components/Dialog/Templates/DialogRender.razor new file mode 100644 index 0000000000..147b9c1fad --- /dev/null +++ b/tests/Core/Components/Dialog/Templates/DialogRender.razor @@ -0,0 +1,60 @@ +@implements IDisposable + + Hello @Name + + +@code { + + private System.Threading.Timer? _timer; + + [CascadingParameter] + public required IDialogInstance Dialog { get; set; } + + [Parameter] + public string? Name { get; set; } + + [Parameter] + public DialogRenderOptions Options { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + await Task.Delay(10); + Options.OnInitializedCount++; + } + + protected override async Task OnParametersSetAsync() + { + await Task.Delay(10); + Options.OnParametersSetCount++; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + if (Options.AutoClose && Options.AutoCloseDelay > 0) + { + _timer = new System.Threading.Timer(async _ => + { + await Dialog.CloseAsync(Options.AutoCloseResult); + }, null, Options.AutoCloseDelay, 5000); + } + + return; + } + + if (Options.AutoClose && Options.AutoCloseDelay <= 0) + { + await Task.Delay(10); + await Dialog.CloseAsync(Options.AutoCloseResult); + } + } + + public void Dispose() + { + if (_timer is not null) + { + _timer.Dispose(); + } + } +} diff --git a/tests/Core/Components/Dialog/Templates/DialogRenderOptions.cs b/tests/Core/Components/Dialog/Templates/DialogRenderOptions.cs new file mode 100644 index 0000000000..206cecdaf8 --- /dev/null +++ b/tests/Core/Components/Dialog/Templates/DialogRenderOptions.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Dialog.Templates; + +public class DialogRenderOptions +{ + public bool AutoClose { get; set; } + + public int AutoCloseDelay { get; set; } + + public DialogResult AutoCloseResult { get; set; } = DialogResult.Ok(true); + + public int OnInitializedCount { get; set; } + + public int OnParametersSetCount { get; set; } +} diff --git a/tests/Core/Components/Dialog/Templates/DialogWithInstance.razor b/tests/Core/Components/Dialog/Templates/DialogWithInstance.razor new file mode 100644 index 0000000000..6a0d39eb62 --- /dev/null +++ b/tests/Core/Components/Dialog/Templates/DialogWithInstance.razor @@ -0,0 +1,85 @@ +@implements IDisposable +@inherits FluentDialogInstance + + + Hello @Name + + +@code { + + private System.Threading.Timer? _timer; + + [CascadingParameter] + public required IDialogInstance Dialog { get; set; } + + [Parameter] + public string? Name { get; set; } + + [Parameter] + public DialogRenderOptions Options { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + await Task.Delay(10); + Options.OnInitializedCount++; + } + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + await Task.Delay(10); + Options.OnParametersSetCount++; + } + + // Initialize the dialog + protected override void OnInitializeDialog(DialogOptionsHeader header, DialogOptionsFooter footer) + { + base.OnInitializeDialog(header, footer); + header.Title = $"Dialog Title - {Name}"; + footer.SecondaryAction.Visible = true; + } + + // Handle the action click + protected override async Task OnActionClickedAsync(bool primary) + { + if (primary) + { + await DialogInstance.CloseAsync("Yes"); + } + else + { + await DialogInstance.CancelAsync(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + if (Options.AutoClose && Options.AutoCloseDelay > 0) + { + _timer = new System.Threading.Timer(async _ => + { + await Dialog.CloseAsync(Options.AutoCloseResult); + }, null, Options.AutoCloseDelay, 5000); + } + + return; + } + + if (Options.AutoClose && Options.AutoCloseDelay <= 0) + { + await Task.Delay(10); + await Dialog.CloseAsync(Options.AutoCloseResult); + } + } + + public void Dispose() + { + if (_timer is not null) + { + _timer.Dispose(); + } + } +}