From b7b24147cda8711f494bc9cc01593a14560b9680 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 06:10:31 +0330 Subject: [PATCH 1/2] apply BitOverlay improvements #13105 --- .../Utilities/Overlay/BitOverlay.razor | 8 +- .../Utilities/Overlay/BitOverlay.razor.cs | 258 ++++++++- .../Utilities/Overlay/BitOverlay.scss | 34 +- .../Models/ComponentCatalog.cs | 2 +- .../Utilities/Overlay/BitOverlayDemo.razor | 166 +++++- .../Utilities/Overlay/BitOverlayDemo.razor.cs | 313 ++++++++--- .../Overlay/BitOverlayDemo.razor.scss | 10 +- .../Shared/MainLayout.razor.NavItems.cs | 2 +- .../Utilities/Overlay/BitOverlayTests.cs | 498 ++++++++++++++++-- 9 files changed, 1119 insertions(+), 172 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor index c8d1ced563d..d5030f340a9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor @@ -1,11 +1,15 @@ -@namespace Bit.BlazorUI +@namespace Bit.BlazorUI @inherits BitComponentBase +@* An Overlay that hosts no content and carries no accessible name of its own is a purely decorative + layer - a dim scrim or a transparent click catcher - so it is taken out of the accessibility tree the + way every surveyed backdrop is; one given content is the very thing a screen reader is meant to reach. *@
@ChildContent -
\ No newline at end of file + diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor.cs index c1816b77f64..76ea5afdbe7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.razor.cs @@ -1,12 +1,27 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; /// -/// The Overlay component is used to provide emphasis on a particular element or parts of it. It signals to the user of a state change within the application and can be used for creating loaders, dialogs and more. +/// The Overlay component is used to provide emphasis on a particular element or parts of it. It signals to +/// the user of a state change within the application and can be used for creating loaders, dialogs and more. /// +/// +/// The Overlay is the low-level layer the dialog surfaces of the library (Modal, Panel, Dialog) are built +/// on: a single element that covers the screen - or, with , the container it +/// was declared inside of - catches the clicks meant for what it covers, and shows whatever content it was +/// given. The dialog behaviors (focus trapping, Escape dismissal, page holding) belong to those surfaces; +/// what the Overlay itself offers is the layer, the click handling and the scrollbar toggle. +/// public partial class BitOverlay : BitComponentBase { private float _offsetTop; private bool _internalIsOpen; + // Whether the overflow of a scroller was actually toggled during the open sequence, so the close + // sequence hands it back if and only if it was taken, regardless of later changes to AutoToggleScroll. + // The scroller is snapshotted with it, so the close restores the same one even when ScrollerElement or + // ScrollerSelector changed while the Overlay was open. + private bool _scrollToggledOnOpen; + private ElementReference? _scrollerElementOnToggle; + private string? _scrollerSelectorOnToggle; @@ -14,22 +29,63 @@ public partial class BitOverlay : BitComponentBase + // The scroller of the application shell the Overlay was declared inside of, cascaded by BitAppShell + // under this name. A shell scrolls a region of its own rather than the page, so the body of such an app + // never scrolls and toggling its overflow takes nothing away: this is the element to toggle instead, for + // an Overlay that has not been pointed at a scroller of its own. Taken by name rather than off + // BitAppShell.Container because the shell lives in Bit.BlazorUI.Extras, which this assembly cannot + // reference. + [CascadingParameter(Name = "BitAppShell.Container")] + private ElementReference? AppShellContainer { get; set; } + + + + /// + /// When true, the Overlay will be positioned absolute instead of fixed, so that it covers the element + /// it was declared inside of rather than the screen. + /// + /// + /// The element it is declared inside of has to establish a containing block of its own + /// (position: relative) for this to place the Overlay over it rather than over the page. + /// + [Parameter, ResetClassBuilder, ResetStyleBuilder] + public bool AbsolutePosition { get; set; } + /// - /// When true, the scroll behavior of the Scroller element behind the overlay will be disabled. + /// When true, the scroll behavior of the scroller element behind the overlay will be disabled while the + /// Overlay is open and handed back once it closes. /// + /// + /// The scroller is named by or ; when + /// neither is set it is the scroller of the application shell the Overlay is inside of, and the page + /// (body) when it is inside none. The room the scrollbar gave back is what an + /// Overlay is pushed down by, so that it stays where the eye left it + /// rather than jumping to the top of the scroller it is laid out in. + /// [Parameter] public bool AutoToggleScroll { get; set; } /// - /// When true, the Overlay will be positioned absolute instead of fixed. + /// Centers the content of the Overlay horizontally and vertically. /// + /// + /// The Overlay lays its content out as a flex container, which stretches it over the whole layer when + /// this is not set - the layout a surface of the consumer's own wants. Centering is what a loader or a + /// message wants, and setting it here saves the stylesheet that would otherwise carry nothing else. + /// [Parameter, ResetClassBuilder] - public bool AbsolutePosition { get; set; } + public bool Center { get; set; } /// /// The content of the Overlay. /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// The initial opening state of the Overlay in the uncontrolled mode, which is when the + /// parameter is not set. + /// + [Parameter] public bool? DefaultIsOpen { get; set; } + /// /// When true, the Overlay and its content will be shown. /// @@ -37,32 +93,139 @@ public partial class BitOverlay : BitComponentBase public bool IsOpen { get; set; } /// - /// When true, the Overlay will be closed by clicking on it. + /// Renders the Overlay in full mode that gives it an opaque background using the theme's overlay + /// background color. + /// + /// + /// The Overlay catches the clicks meant for what it covers either way; this is what makes it dim it as + /// well, without a stylesheet of the consumer's own. It is transparent otherwise, for the overlays that + /// are a click catcher rather than a backdrop. + /// + [Parameter, ResetClassBuilder] + public bool ModeFull { get; set; } + + /// + /// When true, the Overlay will not be closed by clicking on it. /// + /// + /// The click is still reported through , which is what makes that the place to + /// react to a click the Overlay refuses to be closed by. + /// [Parameter] public bool NoAutoClose { get; set; } /// /// Callback that is called when the overlay is clicked. /// + /// + /// Invoked for every click on an open Overlay, including the ones a Overlay + /// refuses to be closed by, and invoked before the Overlay closes. + /// [Parameter] public EventCallback OnClick { get; set; } /// - /// Set the selector of the Selector element for the Overlay to disable its scroll if applicable. + /// The element reference of the scroller whose scrolling is taken away while the Overlay is open, for + /// the layouts whose scroller is not the page and cannot be named by a selector. /// + /// + /// Takes precedence over when both are set, and over the scroller a + /// BitAppShell cascades. Read only by , which is what takes the + /// scrollbar away. + /// + [Parameter] public ElementReference? ScrollerElement { get; set; } + + /// + /// The CSS selector of the scroller element whose scrolling is taken away while the Overlay is open, + /// for . + /// + /// + /// An Overlay inside a BitAppShell holds the shell's scroller without being told to, since the + /// shell cascades it; the page (body) is what is held when there is no shell and this is not + /// set, which is the scroller of an ordinary page. Any other layout that scrolls a region of its own + /// names that region here, since holding a page that never scrolls holds nothing. + /// [Parameter] public string? ScrollerSelector { get; set; } + /// + /// The layer the Overlay is stacked at, which takes over from the one the whole library shares. + /// + /// + /// It is what an Overlay that has to sit above (or below) another surface of the page needs: a fixed + /// Overlay sits at the library's shared overlay layer otherwise, and an + /// one carries no z-index of its own at all, covering nothing but its own earlier siblings. + /// + [Parameter, ResetStyleBuilder] + public int? ZIndex { get; set; } + + + + /// + /// Opens the Overlay, unless it is disabled. + /// + public async Task Open() + { + if (IsEnabled is false) return; + + if (IsOpen) return; + + if (await AssignIsOpen(true) is false) return; + + StateHasChanged(); + } + + /// + /// Closes the Overlay. It closes whether or not the Overlay is enabled, so that an Overlay disabled + /// while it was open can still be taken off the screen by the code that owns it. + /// + public async Task Close() + { + if (IsOpen is false) return; + + if (await AssignIsOpen(false) is false) return; + + StateHasChanged(); + } + + /// + /// Opens the Overlay when it is closed, and closes it when it is open. + /// + public Task Toggle() => IsOpen ? Close() : Open(); + protected override string RootElementClass => "bit-ovl"; + protected override void OnInitialized() + { + // The uncontrolled starting state, which only applies while the consumer is not driving IsOpen + // itself. It is read once here rather than every time the parameters are set, so that closing an + // uncontrolled Overlay is not undone by the next render. + if (IsOpenHasBeenSet is false && DefaultIsOpen.HasValue) + { + IsOpen = DefaultIsOpen.Value; + } + + base.OnInitialized(); + } + protected override void RegisterCssStyles() { - StyleBuilder.Register(() => _offsetTop > 0 ? FormattableString.Invariant($"top:{_offsetTop}px") : string.Empty); + StyleBuilder.Register(() => ZIndex is null + ? string.Empty + : FormattableString.Invariant($"z-index:{ZIndex}")); + + // Only an absolutely positioned Overlay is laid out inside the scroller AutoToggleScroll takes the + // overflow off, so only that one is pushed down by the room it gave back. An Overlay anchored to + // the screen is positioned against the viewport, which never moved. + StyleBuilder.Register(() => AbsolutePosition && _offsetTop > 0 + ? FormattableString.Invariant($"top:{_offsetTop}px") + : string.Empty); } protected override void RegisterCssClasses() { ClassBuilder.Register(() => IsOpen ? "bit-ovl-opn" : string.Empty); + ClassBuilder.Register(() => Center ? "bit-ovl-ctr" : string.Empty); + ClassBuilder.Register(() => ModeFull ? "bit-ovl-mfl" : string.Empty); ClassBuilder.Register(() => AbsolutePosition ? "bit-ovl-abs" : string.Empty); } @@ -74,26 +237,72 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _internalIsOpen = IsOpen; - _offsetTop = 0; + var hadOffset = _offsetTop > 0; - if (AutoToggleScroll is false) return; + _offsetTop = 0; - var scrollerSelector = ScrollerSelector.HasValue() ? ScrollerSelector! : "body"; + await ToggleScroll(IsOpen); + + // The top-offset means nothing to a closed Overlay, whatever the release call reported back. + if (IsOpen is false) + { + _offsetTop = 0; + } + + // Only re-rendered when the offset the style reads actually changed, so an Overlay that toggles + // nothing renders nothing twice. + if (hadOffset != _offsetTop > 0) + { + StyleBuilder.Reset(); + StateHasChanged(); + } + } - _offsetTop = await _js.BitUtilsToggleOverflow(UniqueId, scrollerSelector, IsOpen); - if (AbsolutePosition is false) return; - StyleBuilder.Reset(); + // What the overflow toggle acts on, in the order the consumer's intent is expressed: the element it + // named, then the selector it named, then the scroller of the application shell the Overlay is inside + // of, and the page when it is inside none. + private ElementReference? ScrollerElementTarget => ScrollerElement + ?? (ScrollerSelector.HasValue() ? null : AppShellContainer); - StateHasChanged(); + // The scroll handling the Overlay does itself: the overflow of the scroller is taken away while the + // Overlay is open and handed back once it closes, and the room that gave back is what an absolutely + // positioned Overlay is pushed down by. + private async Task ToggleScroll(bool isOpen) + { + if (isOpen) + { + // The decision is taken at open time; the close reuses it instead of re-reading + // AutoToggleScroll, which may have changed since the Overlay was opened. + _scrollToggledOnOpen = AutoToggleScroll; + if (_scrollToggledOnOpen is false) return; + + // The scroller is snapshotted with it, so the close hands back the same one even if + // ScrollerElement / ScrollerSelector changed in the meantime. + _scrollerElementOnToggle = ScrollerElementTarget; + _scrollerSelectorOnToggle = ScrollerSelector; + } + else + { + // Only hand the overflow back if it was actually taken away, regardless of the current value. + if (_scrollToggledOnOpen is false) return; + + _scrollToggledOnOpen = false; + } + + try + { + _offsetTop = _scrollerElementOnToggle.HasValue + ? await _js.BitUtilsToggleOverflow(UniqueId, _scrollerElementOnToggle.Value, isOpen) + : await _js.BitUtilsToggleOverflow(UniqueId, _scrollerSelectorOnToggle ?? "body", isOpen); + } + catch (JSDisconnectedException) { } // we can ignore this exception here } - - private async Task CloseOverlay(MouseEventArgs e) { - if (IsEnabled is false) return; + if (IsEnabled is false || IsOpen is false) return; await OnClick.InvokeAsync(e); @@ -101,4 +310,17 @@ private async Task CloseOverlay(MouseEventArgs e) await AssignIsOpen(false); } + + + + protected override async ValueTask DisposeAsync(bool disposing) + { + if (IsDisposed || disposing is false) return; + + // An Overlay taken off the page while it was open would otherwise leave the scroller it held + // without its scrollbar for good. + await ToggleScroll(false); + + await base.DisposeAsync(disposing); + } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.scss b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.scss index 0d22383773e..c60e11eab73 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/Overlay/BitOverlay.scss @@ -1,17 +1,45 @@ -@import "../../../Styles/functions.scss"; +@import "../../../Styles/functions.scss"; +// The Overlay stays in the page while it is closed so that it fades out rather than vanishing in one +// frame. It is hidden rather than removed, and a hidden element takes neither the clicks of the page nor +// a place in the accessibility tree, so a closed Overlay leaves the page untouched. The durations +// collapse to nothing under reduced motion, which the motion tokens take care of. .bit-ovl { inset: 0; + opacity: 0; width: 100%; height: 100%; - display: none; + display: flex; position: fixed; + visibility: hidden; + z-index: $zindex-overlay; + transition: opacity $mot-duration-short $mot-easing-accelerate, visibility 0s linear $mot-duration-short; } .bit-ovl-opn { - display: flex; + opacity: 1; + visibility: visible; + transition: opacity $mot-duration-short $mot-easing-decelerate, visibility 0s linear 0s; +} + +// Centered content, for the loaders and the messages; the content stretches over the whole layer +// otherwise, which is the layout a surface of the consumer's own wants. +.bit-ovl-ctr { + align-items: center; + justify-content: center; +} + +// ModeFull: the Overlay dims what it covers instead of only catching the clicks meant for it. It is a +// transparent catcher of clicks otherwise, so that an overlay used as a click catcher does not darken the +// page it is meant to leave readable. +.bit-ovl-mfl { + background-color: $clr-bg-overlay; } +// An Overlay placed over the element it was declared inside of rather than over the screen. The z-index +// goes with the screen-level layer: inside a container the Overlay only has to cover its own siblings, +// and lifting it to the library's overlay layer would raise it over the chrome of the page as well. .bit-ovl-abs { position: absolute; + z-index: unset; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Models/ComponentCatalog.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Models/ComponentCatalog.cs index 20cca2b57cd..f2a4f6dc50a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Models/ComponentCatalog.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Models/ComponentCatalog.cs @@ -459,7 +459,7 @@ private static string Normalize(string url) ["Label"] = "Gives a name to a control or a group of controls.", ["Link"] = "Navigates elsewhere, inside the app or out of it.", ["MediaQuery"] = "Reports the library's breakpoints to your component as a parameter.", - ["Overlay"] = "Dims everything behind a piece of UI to put the emphasis on it.", + ["Overlay"] = "Covers the page or a container, catching its clicks to put the emphasis on what it hosts.", ["Params"] = "Cascades shared parameter objects so components inherit common defaults.", ["PullToRefresh"] = "Adds pull-down-to-refresh to a page or a scrolling element.", ["Separator"] = "Visually divides content into groups, with an optional label.", diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor index a9dadee6ee9..8a661b27b9a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor @@ -1,29 +1,57 @@ -@page "/components/overlay" +@page "/components/overlay" + Description="Overlay is the layer that covers the page - or a container of it - to catch its clicks and host a loader or a dialog over it." /> +
+ IsOpen is what shows the Overlay, and it is two-way bound so that the Overlay can close itself: + a click anywhere on it sets it back to false. The Overlay is transparent by default - it covers the + whole page and catches every click, but shows nothing of its own beyond the content it was given - + and it fades in and out rather than appearing in one frame. Center centers that content over + the layer, which is what a loader wants; without it the content stretches over the whole Overlay. +
+
Show Overlay - + + + +
+
+ + +
+ ModeFull dims what the Overlay covers with the theme's overlay background color, so a backdrop + needs no stylesheet of the consumer's own - and follows the theme wherever it goes. Without it the + Overlay stays transparent, for the overlays that are a click catcher rather than a backdrop. +
+
+
+ Show Overlay +
- -
Use BitOverlay with NoAutoClose to disable automatic closing when clicking outside the overlay.
+ +
+ NoAutoClose keeps a click on the Overlay from closing it, for the overlays whose content has to + be dealt with through the content itself - so the Overlay here is closed through its own close button. +

Show Overlay - +
AutoCloseIsOpen = false) IconName="@BitIconName.ChromeClose" Title="Close" />

Short story

@@ -38,13 +66,18 @@
- -
Overlay can be positioned abosolutely inside a conainter with a relative position.
+ +
+ AbsolutePosition lays the Overlay out against the container it was declared inside of rather + than against the screen, so only that container is covered. The container has to establish a + containing block of its own (position: relative) for the Overlay to be placed over it. +

Show Overlay @@ -52,13 +85,18 @@
- -
Scrolling cannot be left enabled behind the Overlay
+ +
+ AutoToggleScroll takes the scrollbar off the scroller behind the Overlay while it is open and + hands it back once it closes, so the page does not carry on scrolling behind a layer that covers it. + The scroller is the page itself unless a selector names another one. +

Show Overlay Please wait... @@ -68,19 +106,22 @@
- -
Set specific scroller element to toggle the scrollbar.
+ +
+ ScrollerSelector names the element whose scrolling AutoToggleScroll takes away, for the layouts + whose scroller is not the page itself - here the green-bordered box. The first Overlay leaves the box + scrolling behind it; the second takes its scrollbar away until it closes. The room the scrollbar gave + back is what an absolutely positioned Overlay is pushed down by, so it stays where the eye left it. +

- Show with Enabled scrolling Show with Disabled scrolling
@@ -95,7 +136,8 @@ @@ -145,22 +187,88 @@
- -
Use BitOverlay with event handlers to handle custom actions on user interactions, such as clicking outside to close.
+ +
+ OnClick reports every click on an open Overlay, including the ones a NoAutoClose Overlay + refuses to be closed by - which makes it the place to react to a click that was turned down, or to + close the Overlay on terms of the consumer's own, as this one does after three clicks. +

- Show Overlay - EventOnCloseIsOpen = false) NoAutoClose> + Show Overlay +
-

Short story

+

Click anywhere on the overlay

+
The overlay has been clicked @EventsClickCount time(s). It closes on the third click.
+
+
+
+
+ + +
+ An Overlay captured with @@ref can be driven through its Open, Close and + Toggle methods, which is the way to reach one from code that has no field to bind IsOpen to. + The methods and the binding are the same state, so an Overlay opened through Open still closes on a + click. DefaultIsOpen is the state such an uncontrolled Overlay starts in; it is read once, so + closing the Overlay is not undone by the next render. +
+
+
+ Open + +
+

Driven by methods

+
This Overlay has no IsOpen binding of its own: it is opened through the reference to it, and a click anywhere on it still closes it.
+
+
+
+
+ + +
+ Style and Class land on the root element, and the root element is the scrim itself - + so a scrim of any color, a gradient, or a frosted-glass blur is one style away, with no parameter + in between. The styled Overlay here paints a gradient scrim of its own instead of ModeFull; the + classed one blurs what it covers with a backdrop-filter. +
+
+
+ Show styled Overlay + Show classed Overlay + + + + + + + + +
+
+ + +
+ The Overlay itself has no left or right - it covers everything either way - so Dir is about + the content: BitDir.Rtl flows whatever the Overlay hosts right-to-left, for the apps written + in RTL languages. +
+
+
+ نمایش روکش + +
+

داستان کوتاه

- Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment - when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, - for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. + روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. + هر واژه معنایی داشت و هر درنگ، فهمی تازه به همراه می‌آورد. این متن جای‌نگهدار، یادآور لحظه‌ای است + که امکان‌ها بی‌پایان‌اند و در انتظار محتوایی هستند تا شکل بگیرد. این‌جا جایی است که سفر آغاز می‌شود؛ + واژه‌های شما راه را نشان خواهند داد.
- \ No newline at end of file + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs index 965faf52c76..f570354be69 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs @@ -1,22 +1,31 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Utilities.Overlay; +using Microsoft.AspNetCore.Components.Web; + +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Utilities.Overlay; public partial class BitOverlayDemo { private readonly List componentParameters = [ + new() + { + Name = "AbsolutePosition", + Type = "bool", + DefaultValue = "false", + Description = "When true, the Overlay will be positioned absolute instead of fixed, so that it covers the element it was declared inside of rather than the screen. That element has to establish a containing block of its own (position: relative).", + }, new() { Name = "AutoToggleScroll", Type = "bool", DefaultValue = "false", - Description = "When true, the scroll behavior of the Scroller element behind the overlay will be disabled.", + Description = "When true, the scroll behavior of the scroller element behind the overlay will be disabled while the Overlay is open and handed back once it closes. The scroller is named by ScrollerElement or ScrollerSelector, and is the page (body) when neither is set.", }, new() { - Name = "AbsolutePosition", + Name = "Center", Type = "bool", DefaultValue = "false", - Description = "When true, the Overlay will be positioned absolute instead of fixed.", + Description = "Centers the content of the Overlay horizontally and vertically. Without it the content stretches over the whole layer, which is the layout a surface of the consumer's own wants.", }, new() { @@ -26,6 +35,13 @@ public partial class BitOverlayDemo Description = "The content of the Overlay.", }, new() + { + Name = "DefaultIsOpen", + Type = "bool?", + DefaultValue = "null", + Description = "The initial opening state of the Overlay in the uncontrolled mode, which is when the IsOpen parameter is not set.", + }, + new() { Name = "IsOpen", Type = "bool", @@ -33,67 +49,124 @@ public partial class BitOverlayDemo Description = "When true, the Overlay and its content will be shown.", }, new() + { + Name = "ModeFull", + Type = "bool", + DefaultValue = "false", + Description = "Renders the Overlay in full mode that gives it an opaque background using the theme's overlay background color. It is transparent otherwise, for the overlays that are a click catcher rather than a backdrop.", + }, + new() { Name = "NoAutoClose", Type = "bool", DefaultValue = "false", - Description = "When true, the Overlay will be closed by clicking on it.", + Description = "When true, the Overlay will not be closed by clicking on it. The click is still reported through OnClick.", }, new() { Name = "OnClick", Type = "EventCallback", - Description = "Callback for when the toggle button is clicked.", + Description = "Callback that is called when the overlay is clicked, including the clicks a NoAutoClose Overlay refuses to be closed by, and before the Overlay closes.", + }, + new() + { + Name = "ScrollerElement", + Type = "ElementReference?", + DefaultValue = "null", + Description = "The element reference of the scroller whose scrolling is taken away while the Overlay is open, for the layouts whose scroller cannot be named by a selector. Takes precedence over ScrollerSelector.", }, new() { Name = "ScrollerSelector", - Type = "string", - DefaultValue = "body", - Description = "Set the selector of the Selector element for the Overlay to disable its scroll if applicable.", + Type = "string?", + DefaultValue = "null", + Description = "The CSS selector of the scroller element whose scrolling is taken away while the Overlay is open, for AutoToggleScroll. The page (body) is what is held when this is not set.", + }, + new() + { + Name = "ZIndex", + Type = "int?", + DefaultValue = "null", + Description = "The layer the Overlay is stacked at, which takes over from the one the whole library shares - for an Overlay that has to sit above (or below) another surface of the page.", } ]; + private readonly List componentPublicMembers = + [ + new() + { + Name = "Open()", + Type = "() => Task", + Description = "Opens the Overlay, unless it is disabled.", + }, + new() + { + Name = "Close()", + Type = "() => Task", + Description = "Closes the Overlay. It closes whether or not the Overlay is enabled, so that an Overlay disabled while it was open can still be taken off the screen by the code that owns it.", + }, + new() + { + Name = "Toggle()", + Type = "() => Task", + Description = "Opens the Overlay when it is closed, and closes it when it is open.", + }, + ]; + private bool BasicIsOpen; + private bool ModeFullIsOpen; private bool AutoCloseIsOpen; private bool AbsoluteIsOpen; private bool AutoToggleIsOpen; - private bool EventOnCloseIsOpen; private bool EnabledScrollerIsOpen; private bool DisabledScrollerIsOpen; + private bool EventsIsOpen; + private int EventsClickCount; + private BitOverlay overlayRef = default!; + private bool StyledIsOpen; + private bool ClassedIsOpen; + private bool RtlIsOpen; + + private void HandleEventsShow() + { + EventsClickCount = 0; + EventsIsOpen = true; + } + private void HandleOverlayClick(MouseEventArgs e) + { + EventsClickCount++; - - private readonly string example1RazorCode = @" - + + private readonly string example1RazorCode = @" BasicIsOpen = true"">Show Overlay - + "; private readonly string example1CsharpCode = @" private bool BasicIsOpen;"; private readonly string example2RazorCode = @" - - AbsoluteIsOpen = true"">Show Overlay +
+ AbsoluteIsOpen = true"">Show Overlay - - - + + + -

This is Container

"; - private readonly string example3CsharpCode = @" +

This is Container

+
"; + private readonly string example4CsharpCode = @" private bool AbsoluteIsOpen;"; - private readonly string example4RazorCode = @" - - - + private readonly string example5RazorCode = @" AutoToggleIsOpen = true"">Show Overlay - + Please wait... - + "; - private readonly string example4CsharpCode = @" + private readonly string example5CsharpCode = @" private bool AutoToggleIsOpen;"; - private readonly string example5RazorCode = @" + private readonly string example6RazorCode = @" + + +Show Overlay + + +
+

Click anywhere on the overlay

+
The overlay has been clicked @EventsClickCount time(s). It closes on the third click.
+
+
"; + private readonly string example7CsharpCode = @" +private bool EventsIsOpen; +private int EventsClickCount; + +private void HandleEventsShow() +{ + EventsClickCount = 0; + EventsIsOpen = true; +} + +private void HandleOverlayClick(MouseEventArgs e) +{ + EventsClickCount++; + + if (EventsClickCount >= 3) + { + EventsIsOpen = false; } +}"; + private readonly string example8RazorCode = @" + - EventOnCloseIsOpen = true"">Show Overlay - EventOnCloseIsOpen = false) NoAutoClose> + overlayRef.Open()"">Open + +
-

Short story

+

Driven by methods

+
This Overlay has no IsOpen binding of its own: it is opened through the reference to it, and a click anywhere on it still closes it.
+
+
"; + private readonly string example8CsharpCode = @" +private BitOverlay overlayRef = default!;"; + + private readonly string example9RazorCode = @" + + + + StyledIsOpen = true"">Show styled Overlay + ClassedIsOpen = true"">Show classed Overlay + + + + + + + +"; + private readonly string example9CsharpCode = @" +private bool StyledIsOpen; +private bool ClassedIsOpen;"; + + private readonly string example10RazorCode = @" + + + + RtlIsOpen = true"">نمایش روکش + + +
+

داستان کوتاه

- Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - Each word carried meaning, each pause brought understanding. Placeholder text reminds us of that moment - when possibilities are limitless, waiting for content to emerge. The spaces here are open for growth, - for ideas that change minds and spark emotions. This is where the journey begins your words will lead the way. + روزی روزگاری، داستان‌ها میان مردم پیوند می‌ساختند؛ هم‌نوایی صداهایی که رویاهای مشترک می‌آفریدند. + هر واژه معنایی داشت و هر درنگ، فهمی تازه به همراه می‌آورد. این متن جای‌نگهدار، یادآور لحظه‌ای است + که امکان‌ها بی‌پایان‌اند و در انتظار محتوایی هستند تا شکل بگیرد. این‌جا جایی است که سفر آغاز می‌شود؛ + واژه‌های شما راه را نشان خواهند داد.
"; - private readonly string example6CsharpCode = @" -private bool EventOnCloseIsOpen;"; + private readonly string example10CsharpCode = @" +private bool RtlIsOpen;"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.scss index 67b17fea174..6c96ec26a4c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.scss @@ -1,4 +1,4 @@ -@import '../../../../Styles/abstracts/_functions.scss'; +@import '../../../../Styles/abstracts/_functions.scss'; @import '../../../../Styles/abstracts/_bit-css-variables.scss'; .content { @@ -42,11 +42,9 @@ } ::deep { - .overlay { - z-index: 10; - align-items: center; - justify-content: center; - background-color: $bit-color-background-overlay; + .custom-overlay { + backdrop-filter: blur(10px); + background-color: rgba(0, 0, 0, 0.2); } .close-button { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs index 9ea656732e4..afe04a76cdf 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs @@ -147,7 +147,7 @@ public partial class MainLayout new() { Text = "Label", Url = "/components/label" }, new() { Text = "Link", Url = "/components/link", Description = "Anchor" }, new() { Text = "MediaQuery", Url = "/components/mediaquery" }, - new() { Text = "Overlay", Url = "/components/overlay" }, + new() { Text = "Overlay", Url = "/components/overlay", Description = "Backdrop, Scrim", Data = "Mask, Dim, BlockUI, LoadingOverlay, ClickCatcher" }, new() { Text = "Params", Url = "/components/params" }, new() { Text = "PullToRefresh", Url = "/components/pulltorefresh" }, new() { Text = "Separator", Url = "/components/separator" }, diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Overlay/BitOverlayTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Overlay/BitOverlayTests.cs index 89e800e6b05..8c872f880f4 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Overlay/BitOverlayTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/Overlay/BitOverlayTests.cs @@ -1,4 +1,6 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Bunit; namespace Bit.BlazorUI.Tests.Components.Utilities.Overlay; @@ -6,12 +8,14 @@ namespace Bit.BlazorUI.Tests.Components.Utilities.Overlay; [TestClass] public class BitOverlayTests : BunitTestContext { + // An Overlay that hosts no content and carries no accessible name is a purely decorative layer, so it + // reports itself aria-hidden; one given content is the very thing a screen reader is meant to reach. [TestMethod] public void BitOverlayShouldRenderExpectedElement() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); } [TestMethod, @@ -27,7 +31,7 @@ public void BitOverlayShouldRespectIsEnabled(bool isEnabled) var cssClass = isEnabled ? null : " bit-dis"; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod] @@ -35,14 +39,14 @@ public void BitOverlayShouldRespectIsEnabledChangingAfterRender() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); component.Render(parameters => { parameters.Add(p => p.IsEnabled, false); }); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); } [TestMethod, @@ -59,11 +63,11 @@ public void BitOverlayShouldRespectStyle(string style) if (style.HasValue()) { - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } else { - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); } } @@ -72,7 +76,7 @@ public void BitOverlayShouldRespectStyleChangingAfterRender() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); var style = "padding: 1rem;"; component.Render(parameters => @@ -80,7 +84,7 @@ public void BitOverlayShouldRespectStyleChangingAfterRender() parameters.Add(p => p.Style, style); }); - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod, @@ -96,7 +100,7 @@ public void BitOverlayShouldRespectClass(string @class) var cssClass = @class.HasValue() ? $" {@class}" : null; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod] @@ -104,7 +108,7 @@ public void BitOverlayShouldRespectClassChangingAfterRender() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); var cssClass = "test-class"; @@ -113,7 +117,7 @@ public void BitOverlayShouldRespectClassChangingAfterRender() parameters.Add(p => p.Class, cssClass); }); - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod, @@ -129,7 +133,7 @@ public void BitOverlayShouldRespectId(string id) var expectedId = id.HasValue() ? id : component.Instance.UniqueId.ToString(); - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod, @@ -148,11 +152,11 @@ public void BitOverlayShouldRespectDir(BitDir? dir) if (dir.HasValue) { var cssClass = dir is BitDir.Rtl ? " bit-rtl" : null; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } else { - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); } } @@ -161,14 +165,14 @@ public void BitOverlayShouldRespectDirChangingAfterRender() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); component.Render(parameters => { parameters.Add(p => p.Dir, BitDir.Ltr); }); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); } [TestMethod, @@ -190,7 +194,7 @@ public void BitOverlayShouldRespectVisibility(BitVisibility visibility) _ => null }; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod] @@ -198,16 +202,17 @@ public void BitOverlayShouldRespectVisibilityChangingAfterRender() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); component.Render(parameters => { parameters.Add(p => p.Visibility, BitVisibility.Collapsed); }); - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } + // An Overlay with content is no longer decorative, so the content case must not carry aria-hidden. [TestMethod, DataRow("Bit Blazor UI"), DataRow("Bit Blazor UI"), @@ -223,7 +228,27 @@ public void BitOverlayShouldRespectChildContent(string childContent) } }); - component.MarkupMatches(@$"
{childContent}
"); + if (childContent is not null) + { + component.MarkupMatches(@$"
{childContent}
"); + } + else + { + component.MarkupMatches(@"
"); + } + } + + // A content-less Overlay that was given an accessible name is not decorative either, so the name is + // what it renders rather than aria-hidden. + [TestMethod] + public void BitOverlayShouldRespectAriaLabel() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AriaLabel, "loading"); + }); + + component.MarkupMatches(@"
"); } [TestMethod] @@ -247,7 +272,7 @@ public void BitOverlayShouldRespectNoAutoClose(bool noAutoClose) parameters.Add(p => p.NoAutoClose, noAutoClose); }); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); Assert.IsTrue(isOpen); @@ -256,7 +281,7 @@ public void BitOverlayShouldRespectNoAutoClose(bool noAutoClose) var cssClass = noAutoClose ? " bit-ovl-opn" : null; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); if (noAutoClose) { @@ -295,6 +320,102 @@ public void BitOverlayShouldRespectAutoToggleScroll(bool autoToggleScroll) } } + // The toggle acts on the scroller the consumer named, and falls back to the page when none was named. + [TestMethod, + DataRow(".scroller"), + DataRow(null) + ] + public void BitOverlayShouldToggleTheScrollerTheSelectorNames(string scrollerSelector) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsOpen, true); + parameters.Add(p => p.AutoToggleScroll, true); + parameters.Add(p => p.ScrollerSelector, scrollerSelector); + }); + + component.WaitForAssertion(() => + { + var invocation = Context.JSInterop.Invocations["BitBlazorUI.Utils.toggleOverflow"][0]; + Assert.AreEqual(scrollerSelector ?? "body", invocation.Arguments[1]); + Assert.AreEqual(true, invocation.Arguments[2]); + }, TimeSpan.FromSeconds(5)); + } + + // The decision and the scroller are snapshotted at open time, so the close hands the overflow back even + // when AutoToggleScroll was turned off - and hands it back to the scroller it was taken from even when + // the selector was changed - while the Overlay was open. + [TestMethod] + public void BitOverlayShouldHandTheOverflowBackWithTheSnapshotTakenAtOpen() + { + var isOpen = true; + var component = RenderComponent(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + parameters.Add(p => p.AutoToggleScroll, true); + parameters.Add(p => p.ScrollerSelector, ".scroller"); + }); + + component.WaitForAssertion(() => Assert.HasCount(1, Context.JSInterop.Invocations["BitBlazorUI.Utils.toggleOverflow"]), TimeSpan.FromSeconds(5)); + + isOpen = false; + component.Render(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + parameters.Add(p => p.AutoToggleScroll, false); + parameters.Add(p => p.ScrollerSelector, ".another-scroller"); + }); + + component.WaitForAssertion(() => + { + var invocations = Context.JSInterop.Invocations["BitBlazorUI.Utils.toggleOverflow"]; + Assert.HasCount(2, invocations); + Assert.AreEqual(".scroller", invocations[1].Arguments[1]); + Assert.AreEqual(false, invocations[1].Arguments[2]); + }, TimeSpan.FromSeconds(5)); + } + + // An Overlay that never took the overflow away has nothing to hand back, whatever AutoToggleScroll + // says by the time it closes. + [TestMethod] + public void BitOverlayShouldNotHandBackWhatItNeverTook() + { + var isOpen = true; + var component = RenderComponent(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + }); + + isOpen = false; + component.Render(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + parameters.Add(p => p.AutoToggleScroll, true); + }); + + Context.JSInterop.VerifyNotInvoke("BitBlazorUI.Utils.toggleOverflow"); + } + + // An Overlay taken off the page while it was open would otherwise leave the scroller it held without + // its scrollbar for good. + [TestMethod] + public async Task BitOverlayShouldHandTheOverflowBackOnDispose() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsOpen, true); + parameters.Add(p => p.AutoToggleScroll, true); + }); + + component.WaitForAssertion(() => Assert.HasCount(1, Context.JSInterop.Invocations["BitBlazorUI.Utils.toggleOverflow"]), TimeSpan.FromSeconds(5)); + + await Context.DisposeComponentsAsync(); + + var invocations = Context.JSInterop.Invocations["BitBlazorUI.Utils.toggleOverflow"]; + Assert.HasCount(2, invocations); + Assert.AreEqual(false, invocations[1].Arguments[2]); + } + [TestMethod, DataRow(true), DataRow(false) @@ -308,7 +429,7 @@ public void BitOverlayShouldRespectAbsolutePosition(bool absolutePosition) var cssClass = absolutePosition ? " bit-ovl-abs" : null; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } [TestMethod] @@ -316,14 +437,112 @@ public void BitOverlayShouldRespectAbsolutePositionChangingAfterRender() { var component = RenderComponent(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); component.Render(parameters => { parameters.Add(p => p.AbsolutePosition, true); }); - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); + } + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitOverlayShouldRespectModeFull(bool modeFull) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ModeFull, modeFull); + }); + + var cssClass = modeFull ? " bit-ovl-mfl" : null; + + component.MarkupMatches(@$"
"); + } + + [TestMethod] + public void BitOverlayShouldRespectModeFullChangingAfterRender() + { + var component = RenderComponent(); + + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.ModeFull, true); + }); + + component.MarkupMatches(@$"
"); + } + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitOverlayShouldRespectCenter(bool center) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Center, center); + }); + + var cssClass = center ? " bit-ovl-ctr" : null; + + component.MarkupMatches(@$"
"); + } + + [TestMethod] + public void BitOverlayShouldRespectCenterChangingAfterRender() + { + var component = RenderComponent(); + + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.Center, true); + }); + + component.MarkupMatches(@$"
"); + } + + [TestMethod, + DataRow(1300), + DataRow(null) + ] + public void BitOverlayShouldRespectZIndex(int? zIndex) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ZIndex, zIndex); + }); + + if (zIndex.HasValue) + { + component.MarkupMatches(@$"
"); + } + else + { + component.MarkupMatches(@"
"); + } + } + + [TestMethod] + public void BitOverlayShouldRespectZIndexChangingAfterRender() + { + var component = RenderComponent(); + + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.ZIndex, 1300); + }); + + component.MarkupMatches(@"
"); } [TestMethod, @@ -340,14 +559,14 @@ public void BitOverlayShouldRespectIsOpen(bool IsOpen) var cssClass = IsOpen ? " bit-ovl-opn" : null; - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); Assert.AreEqual(IsOpen, isOpenBind); var element = component.Find(".bit-ovl"); element.Click(); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); Assert.IsFalse(isOpenBind); } @@ -361,7 +580,7 @@ public void BitOverlayShouldRespectIsOpenChangingAfterRender() parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); }); - component.MarkupMatches(@"
"); + component.MarkupMatches(@"
"); isOpen = true; component.Render(parameters => @@ -369,9 +588,40 @@ public void BitOverlayShouldRespectIsOpenChangingAfterRender() parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); }); - component.MarkupMatches(@$"
"); + component.MarkupMatches(@$"
"); } + // The uncontrolled starting state, which only applies while the consumer is not driving IsOpen itself. + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitOverlayShouldRespectDefaultIsOpen(bool defaultIsOpen) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultIsOpen, defaultIsOpen); + }); + + var cssClass = defaultIsOpen ? " bit-ovl-opn" : null; + + component.MarkupMatches(@$"
"); + } + + [TestMethod] + public void BitOverlayShouldPreferIsOpenOverDefaultIsOpen() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsOpen, false); + parameters.Add(p => p.DefaultIsOpen, true); + }); + + component.MarkupMatches(@"
"); + } + + // The click only means anything on an Overlay the user can actually see: a closed one is invisible in + // the browser, so a click that reaches it in a test must not fire the callback either. [TestMethod, DataRow(true), DataRow(false) @@ -381,6 +631,7 @@ public void BitOverlayShouldRespectOnClick(bool isEnabled) int clickedValue = 0; var component = RenderComponent(parameters => { + parameters.Add(p => p.IsOpen, true); parameters.Add(p => p.IsEnabled, isEnabled); parameters.Add(p => p.OnClick, () => clickedValue++); }); @@ -391,4 +642,191 @@ public void BitOverlayShouldRespectOnClick(bool isEnabled) var expected = isEnabled ? 1 : 0; Assert.AreEqual(expected, clickedValue); } + + [TestMethod] + public void BitOverlayShouldNotFireOnClickWhileClosed() + { + int clickedValue = 0; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnClick, () => clickedValue++); + }); + + var rootDiv = component.Find(".bit-ovl"); + rootDiv.Click(); + + Assert.AreEqual(0, clickedValue); + } + + // OnClick is reported for the clicks a NoAutoClose Overlay refuses to be closed by, which is what + // makes it the place to react to a click that was turned down. + [TestMethod] + public void BitOverlayShouldFireOnClickOnANoAutoCloseOverlay() + { + int clickedValue = 0; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsOpen, true); + parameters.Add(p => p.NoAutoClose, true); + parameters.Add(p => p.OnClick, () => clickedValue++); + }); + + var rootDiv = component.Find(".bit-ovl"); + rootDiv.Click(); + + Assert.AreEqual(1, clickedValue); + + component.MarkupMatches(@"
"); + } + + [TestMethod] + public async Task BitOverlayShouldRespectOpenCloseToggleMethods() + { + var component = RenderComponent(); + + component.MarkupMatches(@"
"); + + await component.InvokeAsync(() => component.Instance.Open()); + component.MarkupMatches(@"
"); + + await component.InvokeAsync(() => component.Instance.Close()); + component.MarkupMatches(@"
"); + + await component.InvokeAsync(() => component.Instance.Toggle()); + component.MarkupMatches(@"
"); + + await component.InvokeAsync(() => component.Instance.Toggle()); + component.MarkupMatches(@"
"); + } + + // A disabled Overlay takes nothing from the user and is not opened by code either, but the code that + // owns it can always close it: one disabled while it was open would otherwise be stuck on the screen. + [TestMethod] + public async Task BitOverlayMethodsShouldRespectIsEnabled() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + await component.InvokeAsync(() => component.Instance.Open()); + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.IsEnabled, true); + }); + + await component.InvokeAsync(() => component.Instance.Open()); + component.MarkupMatches(@"
"); + + component.Render(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + await component.InvokeAsync(() => component.Instance.Close()); + component.MarkupMatches(@"
"); + } + + // Toggle goes through Open and Close, so it inherits their stance on being disabled: it must not open a + // disabled Overlay, but it must still close one that was disabled while it was open. + [TestMethod] + public async Task BitOverlayToggleShouldRespectIsEnabled() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsEnabled, false); + }); + + await component.InvokeAsync(() => component.Instance.Toggle()); + component.MarkupMatches(@"
"); + + var isOpen = true; + component.Render(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + parameters.Add(p => p.IsEnabled, false); + }); + + component.MarkupMatches(@"
"); + + await component.InvokeAsync(() => component.Instance.Toggle()); + + Assert.IsFalse(isOpen); + component.MarkupMatches(@"
"); + } + + // toggleOverflow reports the scroller's scrollTop, which only an absolutely positioned Overlay uses to + // re-align itself over the container it is laid out in. + [TestMethod] + public void BitOverlayAbsolutePositionShouldCarryTheScrollTopOffsetOfTheToggledScroller() + { + Context.JSInterop.Setup("BitBlazorUI.Utils.toggleOverflow", _ => true).SetResult(120); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsOpen, true); + parameters.Add(p => p.AbsolutePosition, true); + parameters.Add(p => p.AutoToggleScroll, true); + }); + + component.WaitForAssertion( + () => StringAssert.Contains(component.Find(".bit-ovl").GetAttribute("style"), "top:120px"), + TimeSpan.FromSeconds(5)); + } + + // On a fixed Overlay the same declaration would push it off the bottom of the screen. + [TestMethod] + public void BitOverlayFixedOverlayShouldNeverCarryTheScrollTopOffset() + { + Context.JSInterop.Setup("BitBlazorUI.Utils.toggleOverflow", _ => true).SetResult(120); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsOpen, true); + parameters.Add(p => p.AutoToggleScroll, true); + parameters.Add(p => p.Style, "color:red"); + }); + + // Force the style builder to recompute so a stale offset would have a chance to land. + component.Render(parameters => parameters.Add(p => p.Style, "color:blue")); + + var style = component.Find(".bit-ovl").GetAttribute("style") ?? string.Empty; + + Assert.IsFalse(style.Contains("top:"), $"A fixed Overlay must not carry a top offset, got '{style}'."); + } + + // The offset is taken back with the overflow, so a closed Overlay carries nothing of the opening. + [TestMethod] + public void BitOverlayShouldDropTheScrollTopOffsetWhenItCloses() + { + Context.JSInterop.Setup("BitBlazorUI.Utils.toggleOverflow", _ => true).SetResult(120); + + var isOpen = true; + var component = RenderComponent(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + parameters.Add(p => p.AbsolutePosition, true); + parameters.Add(p => p.AutoToggleScroll, true); + }); + + component.WaitForAssertion( + () => StringAssert.Contains(component.Find(".bit-ovl").GetAttribute("style"), "top:120px"), + TimeSpan.FromSeconds(5)); + + isOpen = false; + component.Render(parameters => + { + parameters.Bind(p => p.IsOpen, isOpen, value => isOpen = value); + parameters.Add(p => p.AbsolutePosition, true); + parameters.Add(p => p.AutoToggleScroll, true); + }); + + component.WaitForAssertion(() => + { + var style = component.Find(".bit-ovl").GetAttribute("style") ?? string.Empty; + Assert.IsFalse(style.Contains("top:"), $"A closed Overlay must not carry a top offset, got '{style}'."); + }, TimeSpan.FromSeconds(5)); + } } From 51494808d76cd5c3f5ecf834442e810a91a0d477 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 19:58:10 +0330 Subject: [PATCH 2/2] resolve review comments --- .../Pages/Components/Utilities/Overlay/BitOverlayDemo.razor | 4 +++- .../Components/Utilities/Overlay/BitOverlayDemo.razor.cs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor index 8a661b27b9a..24763fb9ddc 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor @@ -89,7 +89,9 @@
AutoToggleScroll takes the scrollbar off the scroller behind the Overlay while it is open and hands it back once it closes, so the page does not carry on scrolling behind a layer that covers it. - The scroller is the page itself unless a selector names another one. + The scroller is the one named by ScrollerElement, then the one a + selector names; with neither set it is the scroller of the + BitAppShell the Overlay is inside of, and the page (body) only when it is inside none.

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs index f570354be69..9097c9404d7 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/Overlay/BitOverlayDemo.razor.cs @@ -18,7 +18,7 @@ public partial class BitOverlayDemo Name = "AutoToggleScroll", Type = "bool", DefaultValue = "false", - Description = "When true, the scroll behavior of the scroller element behind the overlay will be disabled while the Overlay is open and handed back once it closes. The scroller is named by ScrollerElement or ScrollerSelector, and is the page (body) when neither is set.", + Description = "When true, the scroll behavior of the scroller element behind the overlay will be disabled while the Overlay is open and handed back once it closes. The scroller is named by ScrollerElement, then by ScrollerSelector; when neither is set it is the scroller of the BitAppShell the Overlay is inside of, and the page (body) when it is inside none.", }, new() { @@ -80,7 +80,7 @@ public partial class BitOverlayDemo Name = "ScrollerSelector", Type = "string?", DefaultValue = "null", - Description = "The CSS selector of the scroller element whose scrolling is taken away while the Overlay is open, for AutoToggleScroll. The page (body) is what is held when this is not set.", + Description = "The CSS selector of the scroller element whose scrolling is taken away while the Overlay is open, for AutoToggleScroll. An Overlay inside a BitAppShell holds the shell's scroller without being told to, since the shell cascades it; the page (body) is what is held when there is no shell and this is not set.", }, new() {