From b49ae1aa271edfe22d9bc2628939aa744637abe2 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 11:36:24 +0330 Subject: [PATCH 1/2] let exit animations be played by custom BitModal containers #13558 --- .../Surfaces/Modal/BitModal.razor.cs | 11 +- .../Surfaces/Modal/BitModalContainer.razor | 11 +- .../Surfaces/Modal/BitModalContainerBase.cs | 66 ++++++++- .../Components/Surfaces/Modal/BitModalExit.cs | 6 +- .../Surfaces/Modal/BitModalServiceDemo.razor | 25 +++- .../Modal/BitModalServiceDemo.razor.cs | 89 ++++++++++++- .../BitModalServiceDemo.razor.samples.cs | 72 +++++++++- .../Surfaces/Modal/DemoModalContainer.razor | 22 +++ .../ComponentCatalogTests.cs | 2 + .../Surfaces/Modal/BitModalExitTests.cs | 125 +++++++++++++++++- .../Modal/TestModalReferenceContent.cs | 31 +++++ 11 files changed, 426 insertions(+), 34 deletions(-) create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs index ca23186e3a5..640ebdf89aa 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs @@ -1833,11 +1833,12 @@ protected override async ValueTask DisposeAsync(bool disposing) // page, the drag handlers, the overflow it took off its scroller - are taken back here instead, and // the focus is handed back here too. That is the case of a Modal whose container goes away with it // still open, of one inside a page the app navigated away from, and of one shown through the service - // by a container that does not hand it a BitModalExit (one a consumer wrote), which takes it out of the - // page instead of rendering it closed - dropping the stored focus here would leave every keyboard user - // who closed one standing on the body instead of back on whatever opened it. The built-in container - // renders a closed service Modal closed and waits for it to report its exit (BitModalExit), so that one - // gets the close sequence every other Modal gets and arrives here with nothing left to take back. + // by a container that does not hand it a BitModalExit (one that cascades by hand rather than through + // RenderModal), which takes it out of the page instead of rendering it closed - dropping the stored + // focus here would leave every keyboard user who closed one standing on the body instead of back on + // whatever opened it. A container rendering through RenderModal renders a closed service Modal closed + // and waits for it to report its exit (BitModalExit), so that one gets the close sequence every other + // Modal gets and arrives here with nothing left to take back. if (_escapeWatched || _focusTrapped || _focusStored || _scrollLocked || _scrollForwarded || _dragSetup || _scrollToggledOnOpen) { var watched = _escapeWatched; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainer.razor b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainer.razor index d6ce938d7c9..50c1d6e9fb9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainer.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainer.razor @@ -1,13 +1,4 @@ @namespace Bit.BlazorUI @inherits BitModalContainerBase -@foreach (var modalReference in _modalRefs) -{ - - - - @modalReference.Modal - - - -} +@RenderModals() diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs index 8e17c1c7b2d..5417e014db7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.Logging; namespace Bit.BlazorUI; @@ -148,7 +149,7 @@ internal IReadOnlyList GetOpenModals() /// What the modal rendered for the given reference reports its exit animation played through; see /// . /// - private protected BitModalExit GetExit(TReference modalRef) + private BitModalExit GetExit(TReference modalRef) { if (_exits.TryGetValue(modalRef, out var exit)) return exit; @@ -162,6 +163,64 @@ private protected BitModalExit GetExit(TReference modalRef) return exit; } + /// + /// Renders every modal this container holds, in the order they were opened, each with everything it expects + /// from its container (see ) and keyed by its id, so that a modal keeps its state while + /// the ones around it come and go. + /// + protected RenderFragment RenderModals() => builder => + { + foreach (var modalRef in _modalRefs) + { + BuildModal(builder, modalRef); + } + }; + + /// + /// Renders the modal of the given reference with everything it expects from its container: the reference, the + /// merged parameters, and the hold on its exit that lets it play its exit animation before the container takes + /// it out of the page. + /// + /// + /// For a container that puts markup of its own around each modal; renders them all. + /// Render it inside an element or a component keyed by the modal's Id: what one call renders sits in a + /// region of its own, and the regions a loop renders are told apart by their order alone, so without a key of + /// its own a modal closing ahead of another one would have that one built again from scratch. + ///
+ /// A container that cascades the reference and the parameters by hand instead still renders its modals, but it + /// has no way of hearing one out: a modal it renders is taken out of the page the moment it is closed, with no + /// exit animation. + ///
+ protected RenderFragment RenderModal(TReference modalRef) => builder => BuildModal(builder, modalRef); + + private void BuildModal(RenderTreeBuilder builder, TReference modalRef) + { + // The reference is fixed: the one a modal is keyed by never changes, and a class-typed value that is not + // fixed is handed again to everything reading it on every render of the container - and with it the + // previous parameters of each of those components, a moment before their parent hands them the new ones. + builder.OpenComponent>(0); + builder.SetKey(modalRef.Id); + builder.AddComponentParameter(1, nameof(CascadingValue.Value), modalRef); + builder.AddComponentParameter(2, nameof(CascadingValue.IsFixed), true); + builder.AddComponentParameter(3, nameof(CascadingValue.ChildContent), (RenderFragment)(exitBuilder => + { + exitBuilder.OpenComponent>(0); + exitBuilder.AddComponentParameter(1, nameof(CascadingValue.Name), BitModalExit.CascadingName); + exitBuilder.AddComponentParameter(2, nameof(CascadingValue.Value), GetExit(modalRef)); + exitBuilder.AddComponentParameter(3, nameof(CascadingValue.IsFixed), true); + exitBuilder.AddComponentParameter(4, nameof(CascadingValue.ChildContent), (RenderFragment)(parametersBuilder => + { + // Not fixed: Refresh hands a modal a new set of merged parameters, which a fixed value would never pass on. + parametersBuilder.OpenComponent>(0); + parametersBuilder.AddComponentParameter(1, nameof(CascadingValue.Value), GetMergedParameters(modalRef)); + parametersBuilder.AddComponentParameter(2, nameof(CascadingValue.ChildContent), modalRef.Modal); + parametersBuilder.CloseComponent(); + })); + exitBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + /// /// The parameters the given modal effectively carries: its own merged with the container-level ones. This /// is what the service reads for the options it acts on itself, so that a container-level default reaches @@ -263,7 +322,8 @@ private Task OnCloseModal(TReference modalRef) // it is no longer one of the open modals. A modal that never made it onto the screen, or one that has // already played its way out (the user dismissed it, and it took itself off before the service heard of // it), has nothing left to play and goes at once - and so does one rendered by a container that never - // handed it a BitModalExit (one a consumer wrote), which has no way of hearing that it played its way out. + // handed it a BitModalExit (one that cascades by hand rather than through RenderModal), which has no way + // of hearing that it played its way out. if (_modalRefs.Contains(modalRef) && modalRef.Rendered.IsCompletedSuccessfully && modalRef.Rendered.Result && _exits.TryGetValue(modalRef, out var exit) && exit.HasLeft is false) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs index f23f341d2f4..7702c6f7637 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs @@ -10,8 +10,10 @@ /// shared between modals. A Modal stops the cascade at its content, so a Modal declared inside a modal the service /// shows is never mistaken for that modal. ///
-/// Only a container that cascades one keeps a closed modal in the page: one that does not (a container a consumer -/// wrote by hand) has nothing to hear the modal out through, and takes it out of the page at once, as it always has. +/// Only a container that cascades one keeps a closed modal in the page, which is every container that renders its +/// modals through or +/// : one that cascades the reference and the +/// parameters by hand has nothing to hear the modal out through, and takes it out of the page at once. /// internal sealed class BitModalExit { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor index 2e0ab4e1484..3a1719d3355 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor @@ -178,7 +178,26 @@ - + +
+ A container of your own derives from BitModalContainerBase<BitModalReference, BitModalParameters>, + names its ModalService and how the parameters merge, and renders each modal through + RenderModal - inside an element keyed by the modal's Id - or all of them through + RenderModals. Either one hands the modal what it expects from its container, including what lets + it play its exit animation: a container that cascades the reference and the parameters by hand takes a + closed modal out of the page at once. +
+
+
+ Show at the start + Show at the end +
+ + +
+ +
CloseIcon takes the close button's icon from an external library (FontAwesome, Bootstrap Icons, ...) and wins over CloseIconName, a built-in Fluent UI icon. @@ -188,7 +207,7 @@ External close icon - +
Style and Class reach the root of the modal; Styles and Classes reach each part. The public --bit-Modal-* variables (see the BitModal @@ -202,7 +221,7 @@
- +
Dir = BitDir.Rtl lays the modal out right-to-left, chrome included; Start/End positions follow along. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs index be09081db82..31ab87e4c1c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs @@ -252,7 +252,7 @@ public partial class BitModalServiceDemo : IDisposable { Id = "modal-container", Title = "BitModalContainer", - Description = "The component that renders the modals of the service. Mount one, in the layout.", + Description = "The component that renders the modals of the service. Mount one, in the layout - or a container of your own, derived from BitModalContainerBase.", Parameters = [ new() @@ -274,6 +274,64 @@ public partial class BitModalServiceDemo : IDisposable ] }, new() + { + Id = "modal-container-base", + Title = "BitModalContainerBase", + Description = "The base of BitModalContainer, and of a container of your own (BitModalContainerBase): it attaches to the service, keeps the list of modals, closes them on navigation and when it goes away, and keeps a closed modal in the page until its exit animation has played. What a derived container writes:", + Parameters = + [ + new() + { + Name = "ModalService", + Type = "BitModalServiceBase", + DefaultValue = "", + Description = "Abstract: the service this container renders the modals of. Read when the container initializes and not to be changed afterwards." + }, + new() + { + Name = "MergeParameters", + Type = "TParameters? (TParameters? modalParameters, TParameters? containerParameters)", + DefaultValue = "", + Description = "Abstract: merges the parameters of one showing, which win, with the container's ModalParameters - BitModalParameters.Merge for a BitModalService." + }, + new() + { + Name = "GetCloseOnNavigation", + Type = "bool? (TReference modalRef)", + DefaultValue = "null", + Description = "Virtual: whether the given modal closes when the path changes, or null for the default, which closes every modal but a persistent one." + }, + new() + { + Name = "_modalRefs", + Type = "List", + DefaultValue = "", + Description = "The modals this container renders, in the order they were opened - a closed one still playing its exit animation included." + }, + new() + { + Name = "RenderModal", + Type = "RenderFragment (TReference modalRef)", + DefaultValue = "", + Description = "Renders one modal with everything it expects from its container: the reference, the merged parameters and what lets it play its exit animation. Render it inside an element keyed by the modal's Id, so that one closing ahead of another leaves that one as it was. A container that cascades the reference and the parameters by hand instead takes a closed modal out of the page at once, with no exit animation." + }, + new() + { + Name = "RenderModals", + Type = "RenderFragment ()", + DefaultValue = "", + Description = "Renders every modal through RenderModal, each keyed by its Id - all a container with no markup of its own around each modal renders." + }, + new() + { + Name = "GetMergedParameters", + Type = "TParameters? (TReference modalRef)", + DefaultValue = "", + Description = "The merged parameters of the given modal, cached until the container's ModalParameters change or Refresh is called." + } + ] + }, + new() { Id = "modal-parameters", Title = "BitModalParameters", @@ -631,6 +689,35 @@ await paramsModalService.Show(new BitModalParameters } + // The custom container renders a service of the example's own, like the persistent one: an app mounts one + // container, so its own goes in the layout in place of the BitModalContainer. + private readonly BitModalService customModalService = new(); + + private async Task ShowCustomContainerModal() + { + await customModalService.Show(new BitModalParameters + { + MaxWidth = "24rem", + HeaderText = "At the start", + ShowCloseButton = true, + Modeless = true, + Position = BitPosition.TopStart + }); + } + + private async Task ShowOtherCustomContainerModal() + { + await customModalService.Show(new BitModalParameters + { + MaxWidth = "24rem", + HeaderText = "At the end", + ShowCloseButton = true, + Modeless = true, + Position = BitPosition.TopEnd + }); + } + + private async Task ShowExternalIconModal() { await modalService.Show(new BitModalParameters diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs index c9c406bb4d8..5bd689afef7 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs @@ -555,11 +555,73 @@ await paramsModalService.Show(new BitModalParameters }); }"; + private const string demoModalContainerCode = @" +@inherits BitModalContainerBase + +
Open in this container: [@_modalRefs.Count(m => m.IsClosed is false)]
+ +@foreach (var modalRef in _modalRefs) +{ + @* Keyed by the modal, so that one closing ahead of another leaves that one as it was. *@ +
+ @RenderModal(modalRef) +
+} + +@code { + [Parameter, EditorRequired] public BitModalService Service { get; set; } = default!; + + protected override BitModalServiceBase ModalService => Service; + + protected override BitModalParameters? MergeParameters(BitModalParameters? modalParameters, BitModalParameters? containerParameters) + { + return BitModalParameters.Merge(modalParameters, containerParameters); + } +}"; + private readonly string example11RazorCode = @" +Show at the start +Show at the end + +@* in an app, in the layout in place of the BitModalContainer, handed the service registered in DI *@ +"; + private readonly string example11CsharpCode = @" +private readonly BitModalService customModalService = new(); + +private async Task ShowCustomContainerModal() +{ + await customModalService.Show(new BitModalParameters + { + MaxWidth = ""24rem"", + HeaderText = ""At the start"", + ShowCloseButton = true, + Modeless = true, + Position = BitPosition.TopStart + }); +} + +private async Task ShowOtherCustomContainerModal() +{ + await customModalService.Show(new BitModalParameters + { + MaxWidth = ""24rem"", + HeaderText = ""At the end"", + ShowCloseButton = true, + Modeless = true, + Position = BitPosition.TopEnd + }); +}"; + + private readonly DemoCodeFile[] example11CodeFiles = + [ + new("DemoModalContainer.razor", demoModalContainerCode), + ]; + + private readonly string example12RazorCode = @" External close icon"; - private readonly string example11CsharpCode = @" + private readonly string example12CsharpCode = @" [AutoInject] private BitModalService modalService = default!; private async Task ShowExternalIconModal() @@ -573,13 +635,13 @@ await modalService.Show(new BitModalParameters }); }"; - private readonly string example12RazorCode = @" + private readonly string example13RazorCode = @" @* in the layout: the house style of every modal *@ CSS variables Styles"; - private readonly string example12CsharpCode = @" + private readonly string example13CsharpCode = @" [AutoInject] private BitModalService modalService = default!; // in the layout @@ -614,11 +676,11 @@ await modalService.Show(new BitModalParameters }); }"; - private readonly string example13RazorCode = @" + private readonly string example14RazorCode = @"
باز کردن مُدال
"; - private readonly string example13CsharpCode = @" + private readonly string example14CsharpCode = @" [AutoInject] private BitModalService modalService = default!; private async Task ShowRtlModal() diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor new file mode 100644 index 00000000000..477392637f6 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor @@ -0,0 +1,22 @@ +@inherits BitModalContainerBase + +
Open in this container: [@_modalRefs.Count(m => m.IsClosed is false)]
+ +@foreach (var modalRef in _modalRefs) +{ + @* Keyed by the modal, so that one closing ahead of another leaves that one as it was. *@ +
+ @RenderModal(modalRef) +
+} + +@code { + [Parameter, EditorRequired] public BitModalService Service { get; set; } = default!; + + protected override BitModalServiceBase ModalService => Service; + + protected override BitModalParameters? MergeParameters(BitModalParameters? modalParameters, BitModalParameters? containerParameters) + { + return BitModalParameters.Merge(modalParameters, containerParameters); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs index 0df01210e84..3c5457127e7 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs @@ -471,6 +471,8 @@ public async Task A_service_documents_its_own_types_in_full() StringAssert.Contains(answer, "Action", "BitModalService does not document the Update that changes some of the parameters."); StringAssert.Contains(answer, "## BitModalContainer (component)", "BitModalService does not document its container."); StringAssert.Contains(answer, "BitModalService?", "BitModalService does not document the Service a container can be handed."); + StringAssert.Contains(answer, "## BitModalContainerBase", "BitModalService does not document the base a container of an app's own derives from."); + StringAssert.Contains(answer, "RenderModal", "BitModalService does not say how a container of an app's own renders a modal that can play its exit animation."); } /// diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs index d121b3d52cc..1a863053d0c 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs @@ -267,17 +267,17 @@ public async Task BitModalServiceShouldTakeAModalOutAtOnceFromAContainerThatCann await modalRef.Close(); - // A container a consumer wrote hands the modal no BitModalExit, so it has no way of hearing that the modal - // played its way out: the modal goes at once, as it always has, and the application closing it is still not - // reported as a dismissal. + // A container that cascades by hand rather than through RenderModal hands the modal no BitModalExit, so it has + // no way of hearing that the modal played its way out: the modal goes at once, and the application closing it + // is still not reported as a dismissal. container.WaitForAssertion(() => Assert.AreEqual(0, container.FindAll(".bit-mdl").Count)); Assert.AreEqual(0, dismissed); Assert.AreEqual(0, Context.JSInterop.Invocations[WaitForAnimations].Count); Assert.AreEqual(0, ModalService.OpenModals.Count); } - // A container written by hand the way a consumer outside the library writes one: it can cascade the reference and - // the parameters, but not the BitModalExit, which is internal to the library. + // A container that cascades by hand instead of rendering through RenderModal: it can cascade the reference and the + // parameters, but not the BitModalExit, which is internal to the library. private sealed class HandWrittenModalContainer : BitModalContainerBase { [Inject] private BitModalService _modalService { get; set; } = default!; @@ -308,6 +308,121 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } + [TestMethod] + public async Task BitModalServiceShouldKeepAClosedModalInThePageOfAContainerRenderingThroughTheHelper() + { + var animation = Context.JSInterop.SetupVoid(WaitForAnimations, _ => true); + + var dismissed = 0; + + var container = RenderComponent(); + + var modalRef = await ModalService.Show(new BitModalParameters + { + OnDismiss = EventCallback.Factory.Create(this, () => dismissed++), + }); + + container.WaitForAssertion(() => Assert.AreEqual(1, container.FindAll(".helper-modal .bit-mdl").Count)); + + await modalRef.Close(); + + container.WaitForAssertion(() => Assert.AreEqual(1, Context.JSInterop.Invocations[WaitForAnimations].Count)); + + // A container a consumer wrote hears the modal out the way the built-in one does, once it renders it through + // RenderModal: closed, no longer one of the open modals, still in the page on its way out. + Assert.IsTrue(modalRef.IsClosed); + Assert.AreEqual(0, ModalService.OpenModals.Count); + var root = container.Find(".helper-modal .bit-mdl"); + Assert.IsTrue(root.ClassList.Contains("bit-mdl-lvg")); + Assert.IsTrue(root.HasAttribute("inert")); + + // The application closed it, which is not a dismissal. + Assert.AreEqual(0, dismissed); + + await container.InvokeAsync(() => animation.SetVoidResult()); + + container.WaitForAssertion(() => Assert.AreEqual(0, container.FindAll(".bit-mdl").Count)); + } + + [TestMethod] + public async Task BitModalContainerShouldKeepTheModalsAfterOneThatLeavesAsTheyWere() + { + var container = RenderComponent(); + + var logs = new[] { new List(), new List(), new List() }; + var modalRefs = new List(); + + foreach (var log in logs) + { + modalRefs.Add(await ModalService.Show( + new Dictionary { { nameof(TestModalStateContent.Log), log } })); + } + + container.WaitForAssertion(() => Assert.AreEqual(3, container.FindAll(".bit-mdl").Count)); + + await modalRefs[0].Close(); + + container.WaitForAssertion(() => Assert.AreEqual(2, container.FindAll(".bit-mdl").Count)); + + // Each modal is keyed by its id, so the one that left takes nothing of the others with it: neither of them + // is built again in the place it moved up to. + Assert.AreEqual(1, logs[1].Count); + Assert.AreEqual(1, logs[2].Count); + } + + [TestMethod] + public async Task BitModalContainerShouldHandTheContentItsReferenceOnce() + { + var container = RenderComponent(); + + var modalRef = await ModalService.Show(); + + var content = await modalRef.GetContentAsync(); + + Assert.IsNotNull(content); + Assert.AreSame(modalRef, content.ModalReference); + + var parametersSetCount = content.ParametersSetCount; + + // Every modal shown or closed re-renders the container; the reference of a modal already on the screen has not + // changed, so its content is not handed it over again. + var other = await ModalService.Show(); + + container.WaitForAssertion(() => Assert.AreEqual(2, container.FindAll(".bit-mdl").Count)); + + await other.Close(); + + container.WaitForAssertion(() => Assert.AreEqual(1, container.FindAll(".bit-mdl").Count)); + + Assert.AreEqual(parametersSetCount, content.ParametersSetCount); + } + + // A container written the way a consumer outside the library writes one that puts markup of its own around each + // modal: the modal itself is rendered through RenderModal, inside an element keyed by the modal's id. + private sealed class HelperModalContainer : BitModalContainerBase + { + [Inject] private BitModalService _modalService { get; set; } = default!; + + protected override BitModalServiceBase ModalService => _modalService; + + protected override BitModalParameters? MergeParameters(BitModalParameters? modalParameters, BitModalParameters? containerParameters) + { + return BitModalParameters.Merge(modalParameters, containerParameters); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + foreach (var modalRef in _modalRefs) + { + builder.OpenElement(0, "div"); + builder.SetKey(modalRef.Id); + builder.AddAttribute(1, "class", "helper-modal"); + builder.AddContent(2, RenderModal(modalRef)); + builder.CloseElement(); + } + } + } + [TestMethod] public async Task BitModalDeclaredInsideAServiceModalShouldNotBeMistakenForIt() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs new file mode 100644 index 00000000000..2873505a372 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Bit.BlazorUI.Tests.Components.Surfaces.Modal; + +/// +/// Content that reads its modal's reference off the cascade and counts every time it is handed its parameters, so a +/// test can tell a cascade that hands the reference over once from one that hands it over on every render of the +/// container. It takes no parameters of its own, which would be handed over again whenever the modal re-renders. +/// +public class TestModalReferenceContent : ComponentBase +{ + [CascadingParameter] public BitModalReference? ModalReference { get; set; } + + public int ParametersSetCount { get; private set; } + + protected override void OnParametersSet() + { + ParametersSetCount++; + + base.OnParametersSet(); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "p"); + builder.AddAttribute(1, "class", "test-modal-reference"); + builder.AddContent(2, ModalReference?.Id); + builder.CloseElement(); + } +} From 3927dbfe88157e5aaa38d7ea3f2b888425ee058b Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 13:44:03 +0330 Subject: [PATCH 2/2] code review --- .../Surfaces/Modal/BitModal.razor.cs | 4 +- .../Surfaces/Modal/BitModalContainerBase.cs | 121 ++++++++++------ .../Surfaces/Modal/BitModalContainerEntry.cs | 109 +++++++++++++++ .../Surfaces/Modal/BitModalContainerItem.cs | 29 ++++ .../Surfaces/Modal/BitModalContainerSlot.cs | 34 +++++ .../Components/Surfaces/Modal/BitModalExit.cs | 6 +- src/BlazorUI/BreakingChanges.md | 23 ++++ .../Surfaces/Modal/BitModalServiceDemo.razor | 12 +- .../Modal/BitModalServiceDemo.razor.cs | 73 ++++++---- .../BitModalServiceDemo.razor.samples.cs | 23 +--- .../Surfaces/Modal/DemoModalContainer.razor | 23 +--- .../ComponentCatalogTests.cs | 3 +- .../Surfaces/Modal/BitModalExitTests.cs | 129 +++++++++++++----- .../Modal/TestModalReferenceContent.cs | 12 +- 14 files changed, 448 insertions(+), 153 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerEntry.cs create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerItem.cs create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerSlot.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs index 640ebdf89aa..f9c1d6187a2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.razor.cs @@ -1834,9 +1834,9 @@ protected override async ValueTask DisposeAsync(bool disposing) // the focus is handed back here too. That is the case of a Modal whose container goes away with it // still open, of one inside a page the app navigated away from, and of one shown through the service // by a container that does not hand it a BitModalExit (one that cascades by hand rather than through - // RenderModal), which takes it out of the page instead of rendering it closed - dropping the stored + // RenderModals), which takes it out of the page instead of rendering it closed - dropping the stored // focus here would leave every keyboard user who closed one standing on the body instead of back on - // whatever opened it. A container rendering through RenderModal renders a closed service Modal closed + // whatever opened it. A container rendering through RenderModals renders a closed service Modal closed // and waits for it to report its exit (BitModalExit), so that one gets the close sequence every other // Modal gets and arrives here with nothing left to take back. if (_escapeWatched || _focusTrapped || _focusStored || _scrollLocked || _scrollForwarded || _dragSetup || _scrollToggledOnOpen) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs index 5417e014db7..3102a92fede 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerBase.cs @@ -14,7 +14,7 @@ public abstract class BitModalContainerBase : Component where TParameters : class, new() { private bool _disposed; - protected readonly List _modalRefs = []; + private readonly List _modalRefs = []; // The service this container attached to when it initialized, which is the one it detaches from on dispose // whatever ModalService answers by then. @@ -23,6 +23,13 @@ public abstract class BitModalContainerBase : Component private TParameters? _lastModalParameters; private readonly Dictionary _mergedParametersCache = []; + // How many times the modals were refreshed - all of them, and each one on its own. A modal renders only when + // something of its own changed (BitModalContainerEntry), and a refresh is a change even where the merge hands + // back the very object it had before: the parameters of a modal shown with no container-level ones, changed in + // place. + private int _refreshes; + private readonly Dictionary _modalRefreshes = []; + // What each rendered modal is handed to report its exit animation played, and the modals the service has // closed that are still in the page for that animation to play. private readonly Dictionary _exits = []; @@ -80,6 +87,12 @@ public abstract class BitModalContainerBase : Component /// protected virtual bool? GetCloseOnNavigation(TReference modalRef) => null; + /// + /// The modals this container renders, in the order they were opened - a closed one still playing its exit + /// animation included, which is the only time a container holds a closed one. + /// + protected IReadOnlyList ModalReferences => _modalRefs; + /// @@ -94,6 +107,7 @@ public abstract class BitModalContainerBase : Component { _lastModalParameters = ModalParameters; _mergedParametersCache.Clear(); + _refreshes++; } if (_mergedParametersCache.TryGetValue(modalRef, out var merged)) return merged; @@ -115,6 +129,7 @@ public Task Refresh() return InvokeAsync(() => { _mergedParametersCache.Clear(); + _refreshes++; StateHasChanged(); }); } @@ -130,6 +145,14 @@ public Task Refresh(TReference modalRef) return InvokeAsync(() => { _mergedParametersCache.Remove(modalRef); + + // Only for a modal this container holds: a count kept for any other would outlive it until the + // container goes. + if (_modalRefs.Contains(modalRef)) + { + _modalRefreshes[modalRef] = _modalRefreshes.GetValueOrDefault(modalRef) + 1; + } + StateHasChanged(); }); } @@ -164,60 +187,72 @@ private BitModalExit GetExit(TReference modalRef) } /// - /// Renders every modal this container holds, in the order they were opened, each with everything it expects - /// from its container (see ) and keyed by its id, so that a modal keeps its state while - /// the ones around it come and go. + /// Renders every modal this container holds, in the order they were opened, each with everything it expects from + /// its container: the reference, the merged parameters, and the hold on its exit that lets it play its exit + /// animation before the container takes it out of the page. /// + /// + /// Each modal is keyed by its id, so a modal keeps its state while the ones around it come and go, and renders + /// only when something of its own changed - its parameters, a refresh, its closing - rather than with every + /// render of the container. + ///
+ /// A container that cascades the reference and the parameters by hand instead still renders its modals, but it + /// has no way of hearing one out: a modal it renders is taken out of the page the moment it is closed, with no + /// exit animation. + ///
protected RenderFragment RenderModals() => builder => { foreach (var modalRef in _modalRefs) { - BuildModal(builder, modalRef); + BuildModal(builder, modalRef, key: true); } }; /// - /// Renders the modal of the given reference with everything it expects from its container: the reference, the - /// merged parameters, and the hold on its exit that lets it play its exit animation before the container takes - /// it out of the page. + /// Renders every modal this container holds the way does, each inside markup of the + /// container's own: the template is rendered once per modal, and renders the modal itself through + /// . + /// + /// @(RenderModals(item => @<div class="modal-frame">@item.Modal</div>)) + /// /// /// - /// For a container that puts markup of its own around each modal; renders them all. - /// Render it inside an element or a component keyed by the modal's Id: what one call renders sits in a - /// region of its own, and the regions a loop renders are told apart by their order alone, so without a key of - /// its own a modal closing ahead of another one would have that one built again from scratch. - ///
- /// A container that cascades the reference and the parameters by hand instead still renders its modals, but it - /// has no way of hearing one out: a modal it renders is taken out of the page the moment it is closed, with no - /// exit animation. + /// The markup of each modal is keyed by the modal's id here, so the template needs no key of its own. It renders + /// with every render of the container, while the modal inside it renders only when something of its own changed. ///
- protected RenderFragment RenderModal(TReference modalRef) => builder => BuildModal(builder, modalRef); - - private void BuildModal(RenderTreeBuilder builder, TReference modalRef) + protected RenderFragment RenderModals(RenderFragment> template) { - // The reference is fixed: the one a modal is keyed by never changes, and a class-typed value that is not - // fixed is handed again to everything reading it on every render of the container - and with it the - // previous parameters of each of those components, a moment before their parent hands them the new ones. - builder.OpenComponent>(0); - builder.SetKey(modalRef.Id); - builder.AddComponentParameter(1, nameof(CascadingValue.Value), modalRef); - builder.AddComponentParameter(2, nameof(CascadingValue.IsFixed), true); - builder.AddComponentParameter(3, nameof(CascadingValue.ChildContent), (RenderFragment)(exitBuilder => + ArgumentNullException.ThrowIfNull(template); + + return builder => { - exitBuilder.OpenComponent>(0); - exitBuilder.AddComponentParameter(1, nameof(CascadingValue.Name), BitModalExit.CascadingName); - exitBuilder.AddComponentParameter(2, nameof(CascadingValue.Value), GetExit(modalRef)); - exitBuilder.AddComponentParameter(3, nameof(CascadingValue.IsFixed), true); - exitBuilder.AddComponentParameter(4, nameof(CascadingValue.ChildContent), (RenderFragment)(parametersBuilder => + foreach (var modalRef in _modalRefs) { - // Not fixed: Refresh hands a modal a new set of merged parameters, which a fixed value would never pass on. - parametersBuilder.OpenComponent>(0); - parametersBuilder.AddComponentParameter(1, nameof(CascadingValue.Value), GetMergedParameters(modalRef)); - parametersBuilder.AddComponentParameter(2, nameof(CascadingValue.ChildContent), modalRef.Modal); - parametersBuilder.CloseComponent(); - })); - exitBuilder.CloseComponent(); - })); + var item = new BitModalContainerItem(modalRef, modalBuilder => BuildModal(modalBuilder, modalRef, key: false)); + + builder.OpenComponent(0); + builder.SetKey(modalRef.Id); + builder.AddComponentParameter(1, BitModalContainerSlot.ChildContentName, template(item)); + builder.CloseComponent(); + } + }; + } + + private void BuildModal(RenderTreeBuilder builder, TReference modalRef, bool key) + { + // A fragment kept from an earlier render can still render a modal the container has let go of since, and the + // exit and the parameters looked up for it here would then be kept until the container goes. + if (_modalRefs.Contains(modalRef) is false) return; + + builder.OpenComponent>(0); + if (key) + { + builder.SetKey(modalRef.Id); + } + builder.AddComponentParameter(1, BitModalContainerEntry.ReferenceName, modalRef); + builder.AddComponentParameter(2, BitModalContainerEntry.ParametersName, GetMergedParameters(modalRef)); + builder.AddComponentParameter(3, BitModalContainerEntry.ExitName, GetExit(modalRef)); + builder.AddComponentParameter(4, BitModalContainerEntry.VersionName, _refreshes + _modalRefreshes.GetValueOrDefault(modalRef)); builder.CloseComponent(); } @@ -322,8 +357,8 @@ private Task OnCloseModal(TReference modalRef) // it is no longer one of the open modals. A modal that never made it onto the screen, or one that has // already played its way out (the user dismissed it, and it took itself off before the service heard of // it), has nothing left to play and goes at once - and so does one rendered by a container that never - // handed it a BitModalExit (one that cascades by hand rather than through RenderModal), which has no way - // of hearing that it played its way out. + // handed it a BitModalExit (one that cascades by hand rather than through RenderModals), which has no + // way of hearing that it played its way out. if (_modalRefs.Contains(modalRef) && modalRef.Rendered.IsCompletedSuccessfully && modalRef.Rendered.Result && _exits.TryGetValue(modalRef, out var exit) && exit.HasLeft is false) @@ -345,6 +380,7 @@ private void Remove(TReference modalRef) { _modalRefs.Remove(modalRef); _mergedParametersCache.Remove(modalRef); + _modalRefreshes.Remove(modalRef); _exits.Remove(modalRef); _leaving.Remove(modalRef); @@ -463,6 +499,7 @@ protected virtual void Dispose(bool disposing) _modalRefs.Clear(); _mergedParametersCache.Clear(); + _modalRefreshes.Clear(); _exits.Clear(); _leaving.Clear(); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerEntry.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerEntry.cs new file mode 100644 index 00000000000..d56b73f71e4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerEntry.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Components.Rendering; + +namespace Bit.BlazorUI; + +/// +/// One modal of a container, with everything it expects from its container cascaded to it: its reference, what +/// it reports its exit through () and its merged parameters. +/// +/// +/// It renders only when something of its own modal changed - the merged parameters, a refresh, the modal closing - +/// and not on every render of the container. A cascaded value of a class type is handed again to everything +/// reading it whenever the cascade renders, and with it the previous parameters of each of those components, a +/// moment before their parent hands them the current ones: rendered with the container, every modal and every +/// content reading its reference would go through that each time another modal is shown or closed. Rendered only +/// when its own modal changed, the cascades still hand the content its reference again then, so content reading +/// the state of the reference (its parameters, IsClosed) stays current. +/// +internal sealed class BitModalContainerEntry : IComponent + where TReference : BitModalReferenceBase + where TParameters : class, new() +{ + internal const string ReferenceName = "Reference"; + internal const string ParametersName = "Parameters"; + internal const string ExitName = "Exit"; + internal const string VersionName = "Version"; + + private readonly RenderFragment _renderFragment; + + private RenderHandle _renderHandle; + private bool _hasRendered; + + private TReference _reference = default!; + private TParameters? _parameters; + private BitModalExit _exit = default!; + private int _version; + private bool _isClosed; + + + + public BitModalContainerEntry() + { + _renderFragment = BuildRenderTree; + } + + + + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + var reference = parameters.GetValueOrDefault(ReferenceName)!; + var merged = parameters.GetValueOrDefault(ParametersName); + var exit = parameters.GetValueOrDefault(ExitName)!; + var version = parameters.GetValueOrDefault(VersionName); + + if (_hasRendered && + ReferenceEquals(reference, _reference) && + ReferenceEquals(merged, _parameters) && + ReferenceEquals(exit, _exit) && + version == _version && + reference.IsClosed == _isClosed) + { + return Task.CompletedTask; + } + + _reference = reference; + _parameters = merged; + _exit = exit; + _version = version; + // The modal reads it when it renders (the modal is open until the service closes it), so a modal that has + // just closed has to render once more to start its exit animation. + _isClosed = reference.IsClosed; + _hasRendered = true; + + _renderHandle.Render(_renderFragment); + + return Task.CompletedTask; + } + + + + private void BuildRenderTree(RenderTreeBuilder builder) + { + // Not fixed: the content is handed its reference again whenever its modal changed, so that content reading + // its state stays current. + builder.OpenComponent>(0); + builder.AddComponentParameter(1, nameof(CascadingValue.Value), _reference); + builder.AddComponentParameter(2, nameof(CascadingValue.ChildContent), (RenderFragment)(exitBuilder => + { + exitBuilder.OpenComponent>(0); + exitBuilder.AddComponentParameter(1, nameof(CascadingValue.Name), BitModalExit.CascadingName); + exitBuilder.AddComponentParameter(2, nameof(CascadingValue.Value), _exit); + exitBuilder.AddComponentParameter(3, nameof(CascadingValue.IsFixed), true); + exitBuilder.AddComponentParameter(4, nameof(CascadingValue.ChildContent), (RenderFragment)(parametersBuilder => + { + // Not fixed: a refresh hands the modal a new set of merged parameters, which a fixed value would never pass on. + parametersBuilder.OpenComponent>(0); + parametersBuilder.AddComponentParameter(1, nameof(CascadingValue.Value), _parameters); + parametersBuilder.AddComponentParameter(2, nameof(CascadingValue.ChildContent), _reference.Modal); + parametersBuilder.CloseComponent(); + })); + exitBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerItem.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerItem.cs new file mode 100644 index 00000000000..f5db30dcd6c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerItem.cs @@ -0,0 +1,29 @@ +namespace Bit.BlazorUI; + +/// +/// One modal of a container that puts markup of its own around each modal it renders: what the template handed to +/// +/// is rendered with, once per modal. +/// +/// The modal reference type of the container. +public sealed class BitModalContainerItem where TReference : class +{ + internal BitModalContainerItem(TReference reference, RenderFragment modal) + { + Reference = reference; + Modal = modal; + } + + + + /// + /// The reference of the modal - closed while it plays its exit animation, which is the only time a container + /// holds a closed one. + /// + public TReference Reference { get; } + + /// + /// The modal itself, with everything it expects from its container: render it once, where it goes. + /// + public RenderFragment Modal { get; } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerSlot.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerSlot.cs new file mode 100644 index 00000000000..1ff1f58968a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalContainerSlot.cs @@ -0,0 +1,34 @@ +namespace Bit.BlazorUI; + +/// +/// What a container that puts markup of its own around each modal renders that markup in: a component of no markup +/// of its own, there to carry the modal's key. A key is only told apart from the keys of its siblings, and the +/// markup of a template is not one of the container's own frames to key, so the slot is. +/// +/// +/// It renders every time the container does, so the markup around a modal follows the state of the container; the +/// modal inside it is the , which renders only when its +/// own modal changed. +/// +internal sealed class BitModalContainerSlot : IComponent +{ + internal const string ChildContentName = "ChildContent"; + + private RenderHandle _renderHandle; + + + + public void Attach(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + var childContent = parameters.GetValueOrDefault(ChildContentName); + + _renderHandle.Render(childContent ?? (_ => { })); + + return Task.CompletedTask; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs index 7702c6f7637..06f32c09190 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModalExit.cs @@ -11,9 +11,9 @@ /// shows is never mistaken for that modal. ///
/// Only a container that cascades one keeps a closed modal in the page, which is every container that renders its -/// modals through or -/// : one that cascades the reference and the -/// parameters by hand has nothing to hear the modal out through, and takes it out of the page at once. +/// modals through or its overload taking a +/// template: one that cascades the reference and the parameters by hand has nothing to hear the modal out through, +/// and takes it out of the page at once. /// internal sealed class BitModalExit { diff --git a/src/BlazorUI/BreakingChanges.md b/src/BlazorUI/BreakingChanges.md index f6acb5f58ae..7af6c37f919 100644 --- a/src/BlazorUI/BreakingChanges.md +++ b/src/BlazorUI/BreakingChanges.md @@ -5,6 +5,29 @@ Breaking changes to the public API of the bit BlazorUI packages (`Bit.BlazorUI`, ## vNext (after 10.6.2) +### A modal container of an app's own reads its modals through `ModalReferences` ([#13558](https://github.com/bitfoundation/bitplatform/issues/13558)) + +The list of modals `BitModalContainerBase` kept is no longer a `protected` field: +`_modalRefs` is now the read-only `ModalReferences`, since a container that changed the list itself left the +container's own record of each modal (its exit animation, its merged parameters) out of step with it. +**A container that derived from the base and read `_modalRefs`** reads `ModalReferences` instead. One that +rendered its modals by cascading the reference and the parameters itself renders them through +`RenderModals()` - or `RenderModals(template)` to put markup of its own around each modal - which is the only +way a closed modal plays its exit animation; a container for a `BitModalService` is simplest derived from +`BitModalContainer`, which already names the service, merges the parameters and honours `CloseOnNavigation`: + +```razor +@inherits BitModalContainer + +@(RenderModals(item => @)) +``` + +The modals a container renders also render only when something of their own changes - their parameters +(`Update`, `Refresh`, the container's `ModalParameters`) or their closing - rather than whenever any modal is +shown or closed. Content that reads its `BitModalReference` is still handed it again on each of those changes, +so it follows the reference's `Parameters` and `IsClosed`; content that relied on being rendered again because +another modal came or went calls `StateHasChanged` itself. + ### A disabled component no longer runs the activation handlers written on it ([#13547](https://github.com/bitfoundation/bitplatform/issues/13547)) An HTML event handler written straight on a component (``) lands on diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor index 3a1719d3355..0380d67f321 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor @@ -181,12 +181,12 @@
- A container of your own derives from BitModalContainerBase<BitModalReference, BitModalParameters>, - names its ModalService and how the parameters merge, and renders each modal through - RenderModal - inside an element keyed by the modal's Id - or all of them through - RenderModals. Either one hands the modal what it expects from its container, including what lets - it play its exit animation: a container that cascades the reference and the parameters by hand takes a - closed modal out of the page at once. + A container of your own derives from BitModalContainer - which keeps its Service, the + merging of the parameters and CloseOnNavigation - and writes only its markup, rendering the + modals through RenderModals: all of them, or each inside markup of its own through a template + that renders the modal it is handed (item.Modal). Either one keys each modal by its Id and hands it what it + expects from its container, including what lets it play its exit animation: a container that cascades + the reference and the parameters by hand takes a closed modal out of the page at once.

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs index 31ab87e4c1c..8501385103f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.cs @@ -252,7 +252,7 @@ public partial class BitModalServiceDemo : IDisposable { Id = "modal-container", Title = "BitModalContainer", - Description = "The component that renders the modals of the service. Mount one, in the layout - or a container of your own, derived from BitModalContainerBase.", + Description = "The component that renders the modals of the service. Mount one, in the layout - or a container of your own derived from it, which keeps all of this and adds markup of its own (see BitModalContainerBase).", Parameters = [ new() @@ -277,57 +277,82 @@ public partial class BitModalServiceDemo : IDisposable { Id = "modal-container-base", Title = "BitModalContainerBase", - Description = "The base of BitModalContainer, and of a container of your own (BitModalContainerBase): it attaches to the service, keeps the list of modals, closes them on navigation and when it goes away, and keeps a closed modal in the page until its exit animation has played. What a derived container writes:", + Description = "The base of BitModalContainer: it attaches to the service, keeps the list of modals, closes them on navigation and when it goes away, and keeps a closed modal in the page until its exit animation has played. A container of your own for a BitModalService derives from BitModalContainer, which already names the service, merges the parameters and honours CloseOnNavigation, and writes only its markup; one for a service of a reference and parameters type of your own derives from this base and writes the three members marked abstract and virtual as well. What a derived container uses:", Parameters = [ new() { - Name = "ModalService", - Type = "BitModalServiceBase", + Name = "RenderModals", + Type = "RenderFragment ()", DefaultValue = "", - Description = "Abstract: the service this container renders the modals of. Read when the container initializes and not to be changed afterwards." + Description = "Renders every modal, in the order they were opened, with everything it expects from its container: the reference, the merged parameters and what lets it play its exit animation. Each is keyed by its Id and renders only when something of its own changes - its parameters, a Refresh, its closing - not with every render of the container. A container that cascades the reference and the parameters by hand instead takes a closed modal out of the page at once, with no exit animation." }, new() { - Name = "MergeParameters", - Type = "TParameters? (TParameters? modalParameters, TParameters? containerParameters)", + Name = "RenderModals", + Type = "RenderFragment (RenderFragment> template)", DefaultValue = "", - Description = "Abstract: merges the parameters of one showing, which win, with the container's ModalParameters - BitModalParameters.Merge for a BitModalService." + LinkType = LinkType.Link, + Href = "#modal-container-item", + Description = "Renders every modal the same way, each inside markup of your own: the template is rendered once per modal, renders the modal itself through the Modal of the item it is handed - RenderModals(item => @
@item.Modal
) - and needs no @key - the container keys it. The template follows every render of the container; the modal inside it still renders only when it changes." }, new() { - Name = "GetCloseOnNavigation", - Type = "bool? (TReference modalRef)", - DefaultValue = "null", - Description = "Virtual: whether the given modal closes when the path changes, or null for the default, which closes every modal but a persistent one." + Name = "ModalReferences", + Type = "IReadOnlyList", + DefaultValue = "", + Description = "The modals this container renders, in the order they were opened - a closed one still playing its exit animation included." }, new() { - Name = "_modalRefs", - Type = "List", + Name = "GetMergedParameters", + Type = "TParameters? (TReference modalRef)", DefaultValue = "", - Description = "The modals this container renders, in the order they were opened - a closed one still playing its exit animation included." + Description = "The merged parameters of the given modal, cached until the container's ModalParameters change or Refresh is called." }, new() { - Name = "RenderModal", - Type = "RenderFragment (TReference modalRef)", + Name = "ModalService", + Type = "BitModalServiceBase", DefaultValue = "", - Description = "Renders one modal with everything it expects from its container: the reference, the merged parameters and what lets it play its exit animation. Render it inside an element keyed by the modal's Id, so that one closing ahead of another leaves that one as it was. A container that cascades the reference and the parameters by hand instead takes a closed modal out of the page at once, with no exit animation." + Description = "Abstract: the service this container renders the modals of. Read when the container initializes and not to be changed afterwards." }, new() { - Name = "RenderModals", - Type = "RenderFragment ()", + Name = "MergeParameters", + Type = "TParameters? (TParameters? modalParameters, TParameters? containerParameters)", DefaultValue = "", - Description = "Renders every modal through RenderModal, each keyed by its Id - all a container with no markup of its own around each modal renders." + Description = "Abstract: merges the parameters of one showing, which win, with the container's ModalParameters - BitModalParameters.Merge for a BitModalService." }, new() { - Name = "GetMergedParameters", - Type = "TParameters? (TReference modalRef)", + Name = "GetCloseOnNavigation", + Type = "bool? (TReference modalRef)", + DefaultValue = "null", + Description = "Virtual: whether the given modal closes when the path changes, or null for the default, which closes every modal but a persistent one. Read it off the parameters the modals are shown with - BitModalContainer reads BitModalParameters.CloseOnNavigation off the merged ones - or the modals ignore what they ask for." + } + ] + }, + new() + { + Id = "modal-container-item", + Title = "BitModalContainerItem", + Description = "One modal of a container that puts markup of its own around each modal: the context of the template handed to RenderModals.", + Parameters = + [ + new() + { + Name = "Reference", + Type = "TReference", DefaultValue = "", - Description = "The merged parameters of the given modal, cached until the container's ModalParameters change or Refresh is called." + Description = "The reference of the modal - closed while it plays its exit animation, which is the only time a container holds a closed one." + }, + new() + { + Name = "Modal", + Type = "RenderFragment", + DefaultValue = "", + Description = "The modal itself, with everything it expects from its container: render it once, where it goes." } ] }, diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs index 5bd689afef7..8c7dd365db9 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/BitModalServiceDemo.razor.samples.cs @@ -556,28 +556,11 @@ await paramsModalService.Show(new BitModalParameters }"; private const string demoModalContainerCode = @" -@inherits BitModalContainerBase +@inherits BitModalContainer -
Open in this container: [@_modalRefs.Count(m => m.IsClosed is false)]
+
Open in this container: [@ModalReferences.Count(m => m.IsClosed is false)]
-@foreach (var modalRef in _modalRefs) -{ - @* Keyed by the modal, so that one closing ahead of another leaves that one as it was. *@ -
- @RenderModal(modalRef) -
-} - -@code { - [Parameter, EditorRequired] public BitModalService Service { get; set; } = default!; - - protected override BitModalServiceBase ModalService => Service; - - protected override BitModalParameters? MergeParameters(BitModalParameters? modalParameters, BitModalParameters? containerParameters) - { - return BitModalParameters.Merge(modalParameters, containerParameters); - } -}"; +@(RenderModals(item => @
@item.Modal
))"; private readonly string example11RazorCode = @" Show at the start diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor index 477392637f6..a66391b333a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor @@ -1,22 +1,5 @@ -@inherits BitModalContainerBase +@inherits BitModalContainer -
Open in this container: [@_modalRefs.Count(m => m.IsClosed is false)]
+
Open in this container: [@ModalReferences.Count(m => m.IsClosed is false)]
-@foreach (var modalRef in _modalRefs) -{ - @* Keyed by the modal, so that one closing ahead of another leaves that one as it was. *@ -
- @RenderModal(modalRef) -
-} - -@code { - [Parameter, EditorRequired] public BitModalService Service { get; set; } = default!; - - protected override BitModalServiceBase ModalService => Service; - - protected override BitModalParameters? MergeParameters(BitModalParameters? modalParameters, BitModalParameters? containerParameters) - { - return BitModalParameters.Merge(modalParameters, containerParameters); - } -} +@(RenderModals(item => @
@item.Modal
)) diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs index 3c5457127e7..529672dd8aa 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs @@ -472,7 +472,8 @@ public async Task A_service_documents_its_own_types_in_full() StringAssert.Contains(answer, "## BitModalContainer (component)", "BitModalService does not document its container."); StringAssert.Contains(answer, "BitModalService?", "BitModalService does not document the Service a container can be handed."); StringAssert.Contains(answer, "## BitModalContainerBase", "BitModalService does not document the base a container of an app's own derives from."); - StringAssert.Contains(answer, "RenderModal", "BitModalService does not say how a container of an app's own renders a modal that can play its exit animation."); + StringAssert.Contains(answer, "RenderModals", "BitModalService does not say how a container of an app's own renders a modal that can play its exit animation."); + StringAssert.Contains(answer, "BitModalContainerItem", "BitModalService does not document what the template of a container of an app's own is rendered with."); } /// diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs index 1a863053d0c..96135d8f03a 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalExitTests.cs @@ -267,17 +267,17 @@ public async Task BitModalServiceShouldTakeAModalOutAtOnceFromAContainerThatCann await modalRef.Close(); - // A container that cascades by hand rather than through RenderModal hands the modal no BitModalExit, so it has - // no way of hearing that the modal played its way out: the modal goes at once, and the application closing it - // is still not reported as a dismissal. + // A container that cascades by hand rather than through RenderModals hands the modal no BitModalExit, so it + // has no way of hearing that the modal played its way out: the modal goes at once, and the application + // closing it is still not reported as a dismissal. container.WaitForAssertion(() => Assert.AreEqual(0, container.FindAll(".bit-mdl").Count)); Assert.AreEqual(0, dismissed); Assert.AreEqual(0, Context.JSInterop.Invocations[WaitForAnimations].Count); Assert.AreEqual(0, ModalService.OpenModals.Count); } - // A container that cascades by hand instead of rendering through RenderModal: it can cascade the reference and the - // parameters, but not the BitModalExit, which is internal to the library. + // A container that cascades by hand instead of rendering through RenderModals: it can cascade the reference and + // the parameters, but not the BitModalExit, which is internal to the library. private sealed class HandWrittenModalContainer : BitModalContainerBase { [Inject] private BitModalService _modalService { get; set; } = default!; @@ -291,7 +291,7 @@ private sealed class HandWrittenModalContainer : BitModalContainerBase>(0); builder.SetKey(modalRef.Id); @@ -309,46 +309,64 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } [TestMethod] - public async Task BitModalServiceShouldKeepAClosedModalInThePageOfAContainerRenderingThroughTheHelper() + public async Task BitModalServiceShouldKeepAClosedModalInThePageOfAContainerRenderingThroughATemplate() { var animation = Context.JSInterop.SetupVoid(WaitForAnimations, _ => true); var dismissed = 0; - var container = RenderComponent(); + var container = RenderComponent(); var modalRef = await ModalService.Show(new BitModalParameters { OnDismiss = EventCallback.Factory.Create(this, () => dismissed++), }); - container.WaitForAssertion(() => Assert.AreEqual(1, container.FindAll(".helper-modal .bit-mdl").Count)); + container.WaitForAssertion(() => Assert.AreEqual(1, container.FindAll(".template-modal .bit-mdl").Count)); + Assert.AreEqual("false", container.Find(".template-modal").GetAttribute("data-closed")); await modalRef.Close(); container.WaitForAssertion(() => Assert.AreEqual(1, Context.JSInterop.Invocations[WaitForAnimations].Count)); - // A container a consumer wrote hears the modal out the way the built-in one does, once it renders it through - // RenderModal: closed, no longer one of the open modals, still in the page on its way out. + // A container of an app's own hears the modal out the way the built-in one does, once it renders it through + // RenderModals: closed, no longer one of the open modals, still in the page on its way out. Assert.IsTrue(modalRef.IsClosed); Assert.AreEqual(0, ModalService.OpenModals.Count); - var root = container.Find(".helper-modal .bit-mdl"); + var root = container.Find(".template-modal .bit-mdl"); Assert.IsTrue(root.ClassList.Contains("bit-mdl-lvg")); Assert.IsTrue(root.HasAttribute("inert")); + // The markup around the modal follows the container, which renders it again as the modal closes. + Assert.AreEqual("true", container.Find(".template-modal").GetAttribute("data-closed")); + // The application closed it, which is not a dismissal. Assert.AreEqual(0, dismissed); await container.InvokeAsync(() => animation.SetVoidResult()); - container.WaitForAssertion(() => Assert.AreEqual(0, container.FindAll(".bit-mdl").Count)); + container.WaitForAssertion(() => Assert.AreEqual(0, container.FindAll(".template-modal").Count)); } [TestMethod] public async Task BitModalContainerShouldKeepTheModalsAfterOneThatLeavesAsTheyWere() { - var container = RenderComponent(); + await AssertTheModalsAfterOneThatLeavesAreKept(RenderComponent()); + } + [TestMethod] + public async Task BitModalContainerRenderingThroughATemplateShouldKeepTheModalsAfterOneThatLeavesAsTheyWere() + { + var container = RenderComponent(); + + await AssertTheModalsAfterOneThatLeavesAreKept(container); + + // The markup around each modal is keyed with it, so it went with the modal that left. + Assert.AreEqual(2, container.FindAll(".template-modal").Count); + } + + private async Task AssertTheModalsAfterOneThatLeavesAreKept(IRenderedComponent container) where TContainer : IComponent + { var logs = new[] { new List(), new List(), new List() }; var modalRefs = new List(); @@ -371,7 +389,7 @@ public async Task BitModalContainerShouldKeepTheModalsAfterOneThatLeavesAsTheyWe } [TestMethod] - public async Task BitModalContainerShouldHandTheContentItsReferenceOnce() + public async Task BitModalContainerShouldLeaveAModalAloneWhileOthersComeAndGo() { var container = RenderComponent(); @@ -384,8 +402,8 @@ public async Task BitModalContainerShouldHandTheContentItsReferenceOnce() var parametersSetCount = content.ParametersSetCount; - // Every modal shown or closed re-renders the container; the reference of a modal already on the screen has not - // changed, so its content is not handed it over again. + // Every modal shown or closed re-renders the container, but nothing of a modal already on the screen changed, + // so neither it nor its content is handed anything again. var other = await ModalService.Show(); container.WaitForAssertion(() => Assert.AreEqual(2, container.FindAll(".bit-mdl").Count)); @@ -397,29 +415,76 @@ public async Task BitModalContainerShouldHandTheContentItsReferenceOnce() Assert.AreEqual(parametersSetCount, content.ParametersSetCount); } - // A container written the way a consumer outside the library writes one that puts markup of its own around each - // modal: the modal itself is rendered through RenderModal, inside an element keyed by the modal's id. - private sealed class HelperModalContainer : BitModalContainerBase + [TestMethod] + public async Task BitModalContainerShouldHandTheContentItsReferenceAgainWhenItsModalChanges() { - [Inject] private BitModalService _modalService { get; set; } = default!; + var container = RenderComponent(); - protected override BitModalServiceBase ModalService => _modalService; + var modalRef = await ModalService.Show(new BitModalParameters { HeaderText = "Step 1" }); - protected override BitModalParameters? MergeParameters(BitModalParameters? modalParameters, BitModalParameters? containerParameters) + container.WaitForAssertion(() => Assert.AreEqual("Step 1", container.Find(".test-modal-reference-header").TextContent)); + + // Content reading the state of its reference follows it: an update of its own modal hands it the reference again. + await modalRef.Update(p => p.HeaderText = "Step 2"); + + container.WaitForAssertion(() => Assert.AreEqual("Step 2", container.Find(".test-modal-reference-header").TextContent)); + Assert.AreEqual("Step 2", container.Find(".bit-mdl-hdr").TextContent); + } + + [TestMethod] + public async Task BitModalContainerShouldRenderARefreshedModalAgainWhenTheMergeHandsBackTheSameParameters() + { + // With no container-level parameters the merge hands back the modal's own, so the refresh of a set changed in + // place hands the modal the very object it already has. + var container = RenderComponent(parameters => { - return BitModalParameters.Merge(modalParameters, containerParameters); - } + parameters.Add(p => p.ModalParameters, (BitModalParameters)null!); + }); + + var modalParameters = new BitModalParameters { HeaderText = "before" }; + + var modalRef = await ModalService.Show(modalParameters); + + container.WaitForAssertion(() => Assert.AreEqual("before", container.Find(".bit-mdl-hdr").TextContent)); + + modalParameters.HeaderText = "after"; + + await ModalService.Refresh(modalRef); + + container.WaitForAssertion(() => Assert.AreEqual("after", container.Find(".bit-mdl-hdr").TextContent)); + } + + [TestMethod] + public async Task BitModalContainerOfAnAppsOwnShouldCloseItsModalsOnNavigationAsTheyAsk() + { + var container = RenderComponent(); + + var staying = await ModalService.Show(new BitModalParameters { CloseOnNavigation = false }); + var leaving = await ModalService.Show(); + + container.WaitForAssertion(() => Assert.AreEqual(2, container.FindAll(".bit-mdl").Count)); + + Services.GetRequiredService().NavigateTo("/elsewhere"); + // Derived from BitModalContainer, it reads CloseOnNavigation the way the built-in one does. + container.WaitForAssertion(() => Assert.IsTrue(leaving.IsClosed)); + Assert.IsFalse(staying.IsClosed); + } + + // A container of an app's own the way the demo writes one: derived from BitModalContainer, which names the service, + // merges the parameters and reads CloseOnNavigation, with markup of its own around each modal through a template. + private sealed class TemplateModalContainer : BitModalContainer + { protected override void BuildRenderTree(RenderTreeBuilder builder) { - foreach (var modalRef in _modalRefs) + builder.AddContent(0, RenderModals(item => b => { - builder.OpenElement(0, "div"); - builder.SetKey(modalRef.Id); - builder.AddAttribute(1, "class", "helper-modal"); - builder.AddContent(2, RenderModal(modalRef)); - builder.CloseElement(); - } + b.OpenElement(0, "div"); + b.AddAttribute(1, "class", "template-modal"); + b.AddAttribute(2, "data-closed", item.Reference.IsClosed ? "true" : "false"); + b.AddContent(3, item.Modal); + b.CloseElement(); + })); } } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs index 2873505a372..78d82290c29 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs @@ -4,9 +4,10 @@ namespace Bit.BlazorUI.Tests.Components.Surfaces.Modal; /// -/// Content that reads its modal's reference off the cascade and counts every time it is handed its parameters, so a -/// test can tell a cascade that hands the reference over once from one that hands it over on every render of the -/// container. It takes no parameters of its own, which would be handed over again whenever the modal re-renders. +/// Content that reads its modal's reference off the cascade - its id and the header it is shown with - and counts every +/// time it is handed its parameters, so a test can tell a cascade that hands the reference over when its modal changed +/// from one that hands it over on every render of the container. It takes no parameters of its own, which would be +/// handed over again whenever the modal re-renders. /// public class TestModalReferenceContent : ComponentBase { @@ -27,5 +28,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddAttribute(1, "class", "test-modal-reference"); builder.AddContent(2, ModalReference?.Id); builder.CloseElement(); + + builder.OpenElement(3, "p"); + builder.AddAttribute(4, "class", "test-modal-reference-header"); + builder.AddContent(5, ModalReference?.Parameters?.HeaderText); + builder.CloseElement(); } }