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..f9c1d6187a2 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 + // 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 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) { 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..3102a92fede 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; @@ -13,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. @@ -22,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 = []; @@ -79,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; + /// @@ -93,6 +107,7 @@ public abstract class BitModalContainerBase : Component { _lastModalParameters = ModalParameters; _mergedParametersCache.Clear(); + _refreshes++; } if (_mergedParametersCache.TryGetValue(modalRef, out var merged)) return merged; @@ -114,6 +129,7 @@ public Task Refresh() return InvokeAsync(() => { _mergedParametersCache.Clear(); + _refreshes++; StateHasChanged(); }); } @@ -129,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(); }); } @@ -148,7 +172,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 +186,76 @@ 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: 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, key: true); + } + }; + + /// + /// 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>)) + /// + /// + /// + /// 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 RenderModals(RenderFragment> template) + { + ArgumentNullException.ThrowIfNull(template); + + return builder => + { + foreach (var modalRef in _modalRefs) + { + 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(); + } + /// /// 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 +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 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 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) @@ -285,6 +380,7 @@ private void Remove(TReference modalRef) { _modalRefs.Remove(modalRef); _mergedParametersCache.Remove(modalRef); + _modalRefreshes.Remove(modalRef); _exits.Remove(modalRef); _leaving.Remove(modalRef); @@ -403,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 f23f341d2f4..06f32c09190 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 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 89b1c32922b..d9950434488 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 native control now carries `aria-disabled="true"` beside `disabled` ([#13549](https://github.com/bitfoundation/bitplatform/issues/13549)) Components marked a disabled control in different ways: some wrote the native `disabled` attribute alone, some 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..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 @@ -178,7 +178,26 @@ - + +
+ 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. +
+
+
+ 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..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.", + 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() @@ -274,6 +274,89 @@ public partial class BitModalServiceDemo : IDisposable ] }, new() + { + Id = "modal-container-base", + Title = "BitModalContainerBase", + 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 = "RenderModals", + Type = "RenderFragment ()", + DefaultValue = "", + 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 = "RenderModals", + Type = "RenderFragment (RenderFragment> template)", + DefaultValue = "", + 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 = "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 = "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() + { + 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. 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 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." + } + ] + }, + new() { Id = "modal-parameters", Title = "BitModalParameters", @@ -631,6 +714,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..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 @@ -555,11 +555,56 @@ await paramsModalService.Show(new BitModalParameters }); }"; + private const string demoModalContainerCode = @" +@inherits BitModalContainer + +
Open in this container: [@ModalReferences.Count(m => m.IsClosed is false)]
+ +@(RenderModals(item => @
@item.Modal
))"; + 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 +618,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 +659,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..a66391b333a --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Modal/DemoModalContainer.razor @@ -0,0 +1,5 @@ +@inherits BitModalContainer + +
Open in this container: [@ModalReferences.Count(m => m.IsClosed is false)]
+ +@(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 0df01210e84..529672dd8aa 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests.Mcp/ComponentCatalogTests.cs @@ -471,6 +471,9 @@ 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, "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 d121b3d52cc..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,16 +267,16 @@ 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 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 written by hand the way a consumer outside the library writes one: it can cascade the reference and + // 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 { @@ -291,7 +291,7 @@ private sealed class HandWrittenModalContainer : BitModalContainerBase>(0); builder.SetKey(modalRef.Id); @@ -308,6 +308,186 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } + [TestMethod] + public async Task BitModalServiceShouldKeepAClosedModalInThePageOfAContainerRenderingThroughATemplate() + { + 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(".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 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(".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(".template-modal").Count)); + } + + [TestMethod] + public async Task BitModalContainerShouldKeepTheModalsAfterOneThatLeavesAsTheyWere() + { + 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(); + + 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 BitModalContainerShouldLeaveAModalAloneWhileOthersComeAndGo() + { + 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, 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)); + + await other.Close(); + + container.WaitForAssertion(() => Assert.AreEqual(1, container.FindAll(".bit-mdl").Count)); + + Assert.AreEqual(parametersSetCount, content.ParametersSetCount); + } + + [TestMethod] + public async Task BitModalContainerShouldHandTheContentItsReferenceAgainWhenItsModalChanges() + { + var container = RenderComponent(); + + var modalRef = await ModalService.Show(new BitModalParameters { HeaderText = "Step 1" }); + + 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 => + { + 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) + { + builder.AddContent(0, RenderModals(item => b => + { + 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(); + })); + } + } + [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..78d82290c29 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/TestModalReferenceContent.cs @@ -0,0 +1,37 @@ +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 - 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 +{ + [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(); + + builder.OpenElement(3, "p"); + builder.AddAttribute(4, "class", "test-modal-reference-header"); + builder.AddContent(5, ModalReference?.Parameters?.HeaderText); + builder.CloseElement(); + } +}