diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayBackgroundColor.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayBackgroundColor.razor new file mode 100644 index 0000000000..050c6a99c0 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayBackgroundColor.razor @@ -0,0 +1,18 @@ + +Show Overlay + + + + + +@code { + private bool visible; + + private void HandleOnClose() + { + Console.WriteLine("Custom background color overlay closed"); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayDefault.razor new file mode 100644 index 0000000000..17043853aa --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayDefault.razor @@ -0,0 +1,33 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Extensions + + + + + +
+
+ +Show Overlay + + + + + +@code { + private bool visible; + private JustifyContent justification = JustifyContent.Center; + private HorizontalAlignment alignment = HorizontalAlignment.Center; + + private void HandleOnClose() + { + Console.WriteLine("Overlay closed"); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayFullScreen.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayFullScreen.razor new file mode 100644 index 0000000000..cb02b0911d --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayFullScreen.razor @@ -0,0 +1,19 @@ + +Show Overlay + + + + + +@code { + private bool visible; + + private void HandleOnClose() + { + Console.WriteLine("Full screen overlay closed"); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayInteractive.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayInteractive.razor new file mode 100644 index 0000000000..a11c1ac4ec --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayInteractive.razor @@ -0,0 +1,66 @@ + + + + + + + + + Show Overlay + + + + Increment + Counter: @counter + + + + @if (interactive) + { +
+

Non-interactive zone

+ +
+ } + else + { + + } +
+ + + +@code { + private bool visible; + private bool interactive = true; + private bool interactiveExceptId = true; + private bool fullScreen = true; + private int counter = 0; + + private void HandleOnClose() + { + Console.WriteLine("Overlay closed"); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayTimed.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayTimed.razor new file mode 100644 index 0000000000..36ecda22e6 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayTimed.razor @@ -0,0 +1,33 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Extensions + + + + +
+
+ +Show Overlay + + + + + +@code { + private bool visible; + private JustifyContent justification = JustifyContent.Center; + private HorizontalAlignment alignment = HorizontalAlignment.Center; + + private async Task HandleOnOpen() + { + visible = true; + Console.WriteLine("Overlay opened"); + await Task.Delay(3000); + visible = false; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayTransparent.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayTransparent.razor new file mode 100644 index 0000000000..9a267145cd --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayTransparent.razor @@ -0,0 +1,15 @@ + +Show Overlay + + + + + +@code { + private bool visible; + + private void HandleOnClose() + { + Console.WriteLine("Transparent overlay closed"); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md new file mode 100644 index 0000000000..b4b4bd6450 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md @@ -0,0 +1,46 @@ +--- +title: Overlay +route: /Overlay +--- + +# Overlay + +Overlays are used to temporarily overlay screen content to focus a dialog, progress or other information/interaction. + +## Default + +{{ OverlayDefault }} + +## Timed + +A timed overlay that hides after being shown for 3 seconds + +{{ OverlayTimed }} + +## Transparent overlay + +Overlay with a transparent background + +{{ OverlayTransparent }} + +## Background color + +Overlay with a custom background color + +{{ OverlayBackgroundColor }} + +## Full screen + +Overlay which takes up the whole screen. + +{{ OverlayFullScreen }} + +## Interactive +By using the `Interactive` and `InteractiveExceptId` properties, only the targeted element will not close the FluentOverlay panel. The user can click anywhere else to close the FluentOverlay. +In this example, the FluentOverlay will only close when the user clicks outside the white zone and the user can increment the counter before to close the Overlay. + +{{ OverlayInteractive }} + +## API FluentOverlay + +{{ API Type=FluentOverlay }} diff --git a/src/Core/Components/Overlay/FluentOverlay.razor b/src/Core/Components/Overlay/FluentOverlay.razor new file mode 100644 index 0000000000..045cc563a2 --- /dev/null +++ b/src/Core/Components/Overlay/FluentOverlay.razor @@ -0,0 +1,17 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase + +@if (Visible) +{ +
+
+ @ChildContent +
+
+} diff --git a/src/Core/Components/Overlay/FluentOverlay.razor.cs b/src/Core/Components/Overlay/FluentOverlay.razor.cs new file mode 100644 index 0000000000..5f6eb8b7ba --- /dev/null +++ b/src/Core/Components/Overlay/FluentOverlay.razor.cs @@ -0,0 +1,229 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public partial class FluentOverlay : FluentComponentBase +{ + private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "Overlay/FluentOverlay.razor.js"; + private DotNetObjectReference? _dotNetHelper; + + /// + public FluentOverlay(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-overlay") + .AddClass("prevent-scroll", PreventScroll) + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .AddStyle("cursor", "auto", () => Transparent) + .AddStyle("background-color", string.Create(CultureInfo.InvariantCulture, $"color-mix(in srgb, {BackgroundColor} {Opacity}%, transparent)"), () => !Transparent) + .AddStyle("cursor", "default", () => !Transparent) + .AddStyle("position", FullScreen ? "fixed" : "absolute") + .AddStyle("display", "flex") + .AddStyle("align-items", Alignment.ToAttributeValue()) + .AddStyle("justify-content", Justification.ToAttributeValue()) + .AddStyle("pointer-events", "none", () => Interactive) + .AddStyle("z-index", ZIndex.Overlay.ToString(CultureInfo.InvariantCulture)) + .Build(); + + /// + protected string? StyleContentValue => new StyleBuilder() + .AddStyle("pointer-events", "auto", () => Interactive) + .Build(); + + /// + /// Gets or sets a value indicating whether the overlay is visible. + /// + [Parameter] + public bool Visible { get; set; } + + /// + /// Callback for when overlay visibility changes. + /// + [Parameter] + public EventCallback VisibleChanged { get; set; } + + /// + /// Callback for when the overlay is closed. + /// + [Parameter] + public EventCallback OnClose { get; set; } + + /// + /// Gets or set if the overlay is transparent. + /// + [Parameter] + public bool Transparent { get; set; } = true; + + /// + /// Gets or sets the opacity of the overlay. + /// Default is 40%. + /// + [Parameter] + public double? Opacity { get; set; } = 40; + + /// + /// Gets or sets the alignment of the content to a value. + /// Defaults to Align.Center. + /// + [Parameter] + public HorizontalAlignment Alignment { get; set; } = HorizontalAlignment.Center; + + /// + /// Gets or sets the justification of the content to a value. + /// Defaults to JustifyContent.Center. + /// + [Parameter] + public JustifyContent Justification { get; set; } = JustifyContent.Center; + + /// + /// Gets or sets a value indicating whether the overlay is shown full screen or bound to the containing element. + /// + [Parameter] + public bool FullScreen { get; set; } + + /// + /// Gets or sets a value indicating whether the overlay is interactive, except for the element with the specified . + /// In other words, the elements below the overlay remain usable (mouse-over, click) and the overlay will closed when clicked. + /// + [Parameter] + public bool Interactive { get; set; } + + /// + /// Gets or sets the HTML identifier of the element that is not interactive when the overlay is shown. + /// This property is ignored if is false. + /// + [Parameter] + public string? InteractiveExceptId { get; set; } + + /// + /// Gets of sets a value indicating if the overlay can be dismissed by clicking on it. + /// Default is true. + /// + [Parameter] + public bool Dismissable { get; set; } = true; + + /// + /// Gets or sets the background color. + /// Default NeutralBaseColor token value (#808080). + /// + [Parameter] + public string BackgroundColor { get; set; } = "#808080"; + + /// + [Parameter] + public bool PreventScroll { get; set; } + + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetHelper ??= DotNetObjectReference.Create(this); + await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); + } + } + + /// + protected override async Task OnParametersSetAsync() + { + if (Interactive && JSModule.Imported) + { + if (Visible) + { + // Add a document.addEventListener when Visible is true + await InvokeOverlayInitializeAsync(); + } + else + { + // Remove a document.addEventListener when Visible is false + await InvokeOverlayDisposeAsync(); + } + } + } + + /// + [JSInvokable] + public async Task OnCloseInteractiveAsync(MouseEventArgs e) + { + if (!Dismissable || !Visible) + { + return; + } + + // Remove the document.removeEventListener + await InvokeOverlayDisposeAsync(); + + // Close the overlay + await OnCloseInternalHandlerAsync(e); + } + + /// + public async Task OnCloseHandlerAsync(MouseEventArgs e) + { + if (!Dismissable || !Visible || Interactive) + { + return; + } + + // Close the overlay + await OnCloseInternalHandlerAsync(e); + } + + private async Task OnCloseInternalHandlerAsync(MouseEventArgs e) + { + Visible = false; + + if (VisibleChanged.HasDelegate) + { + await VisibleChanged.InvokeAsync(Visible); + } + + if (OnClose.HasDelegate) + { + await OnClose.InvokeAsync(e); + } + } + + /// + protected override async ValueTask DisposeAsync(IJSObjectReference jsModule) + { + await InvokeOverlayDisposeAsync(); + } + + /// + private async Task InvokeOverlayInitializeAsync() + { + var containerId = FullScreen ? null : Id; + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overlay.Initialize", _dotNetHelper, containerId, InteractiveExceptId); + } + + /// + private async Task InvokeOverlayDisposeAsync() + { + if (JSModule.ObjectReference != null && Interactive) + { + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overlay.overlayDispose", InteractiveExceptId); + } + } +} diff --git a/src/Core/Components/Overlay/FluentOverlay.razor.css b/src/Core/Components/Overlay/FluentOverlay.razor.css new file mode 100644 index 0000000000..61eea638fa --- /dev/null +++ b/src/Core/Components/Overlay/FluentOverlay.razor.css @@ -0,0 +1,8 @@ +.fluent-overlay { + inset: 0px; + display: flex; + pointer-events: auto; + width: 100%; + height: 100%; + overflow: hidden auto; +} \ No newline at end of file diff --git a/src/Core/Components/Overlay/FluentOverlay.razor.ts b/src/Core/Components/Overlay/FluentOverlay.razor.ts new file mode 100644 index 0000000000..ffe5054241 --- /dev/null +++ b/src/Core/Components/Overlay/FluentOverlay.razor.ts @@ -0,0 +1,60 @@ +import { DotNet } from "../../../Core.Scripts/src/d-ts/Microsoft.JSInterop"; + +export namespace Microsoft.FluentUI.Blazor.Overlay { + export function Initialize(dotNetHelper: DotNet.DotNetObject, containerId: string, id: string) { + const _document = document as any; + + if (!_document.fluentOverlayData) { + _document.fluentOverlayData = {}; + } + + if (_document.fluentOverlayData[id]) { + return; + } + + // Store the data + _document.fluentOverlayData[id] = { + + // Click event handler + clickHandler: async function (event: MouseEvent) { + const containerElement = document.getElementById(containerId) as HTMLElement; + const isInsideContainer = isClickInsideContainer(event, containerElement); + const isInsideExcludedElement = !!containerElement && isClickInsideContainer(event, containerElement); + + if (isInsideContainer && !isInsideExcludedElement) { + dotNetHelper.invokeMethodAsync('OnCloseInteractiveAsync', event); + } + } + }; + + // Let the user click on the container (containerId or the entire document) + document.addEventListener('click', _document.fluentOverlayData[id].clickHandler); + } + export function overlayDispose(id: string) { + const _document = document as any; + if (_document.fluentOverlayData[id]) { + + // Remove the event listener + document.removeEventListener('click', _document.fluentOverlayData[id].clickHandler); + + // Remove the data + _document.fluentOverlayData[id] = null; + delete _document.fluentOverlayData[id]; + } + } + function isClickInsideContainer(event: MouseEvent, container: HTMLElement) { + if (!!container) { + const rect = container.getBoundingClientRect(); + + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + } + + // Default is true + return true; + } +} diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 0565cb189c..01a3d9630f 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -57,7 +57,6 @@ - @@ -76,7 +75,7 @@ - + @@ -85,9 +84,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Core/Utilities/ZIndex.cs b/src/Core/Utilities/ZIndex.cs index ea6eb4830e..e9b11a1363 100644 --- a/src/Core/Utilities/ZIndex.cs +++ b/src/Core/Utilities/ZIndex.cs @@ -34,4 +34,9 @@ public static class ZIndex /// ZIndex for the popup components in the . /// public static int DataGridHeaderPopup { get; set; } = 5; + + /// + /// ZIndex for the component. + /// + public static int Overlay { get; set; } = 99990; } diff --git a/tests/Core/Components/Base/ComponentBaseTests.cs b/tests/Core/Components/Base/ComponentBaseTests.cs index c52cc60258..395c2892f1 100644 --- a/tests/Core/Components/Base/ComponentBaseTests.cs +++ b/tests/Core/Components/Base/ComponentBaseTests.cs @@ -52,6 +52,7 @@ public class ComponentBaseTests : Bunit.TestContext { typeof(FluentDragContainer<>), Loader.MakeGenericType(typeof(int))}, { typeof(FluentDropZone<>), Loader.MakeGenericType(typeof(int))}, { typeof(FluentTimePicker<>), Loader.MakeGenericType(typeof(DateTime))}, + { typeof(FluentOverlay), Loader.Default.WithRequiredParameter("Visible", true)} }; /// diff --git a/tests/Core/Components/Overlay/FluentOverlayTests.razor b/tests/Core/Components/Overlay/FluentOverlayTests.razor new file mode 100644 index 0000000000..162989abe0 --- /dev/null +++ b/tests/Core/Components/Overlay/FluentOverlayTests.razor @@ -0,0 +1,33 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using Xunit; +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples; +@inherits Bunit.TestContext +@code +{ + public FluentOverlayTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentOverlay_Default() + { + // Arrange + bool onClosedRaised = false; + + // Act + var cut = Render(@ + + + ); + + cut.Find(".fluent-overlay").Click(); + + Assert.True(onClosedRaised); + + // Assert + cut.Verify(); + } +} +