Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
@namespace Bit.BlazorUI
@inherits BitModalContainerBase<BitModalReference, BitModalParameters>

@foreach (var modalReference in _modalRefs)
{
<CascadingValue @key="modalReference.Id" Value="modalReference">
<CascadingValue Name="@BitModalExit.CascadingName" Value="GetExit(modalReference)" IsFixed="true">
<CascadingValue Value="GetMergedParameters(modalReference)">
@modalReference.Modal
</CascadingValue>
</CascadingValue>
</CascadingValue>
}
@RenderModals()
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,7 +14,7 @@ public abstract class BitModalContainerBase<TReference, TParameters> : Component
where TParameters : class, new()
{
private bool _disposed;
protected readonly List<TReference> _modalRefs = [];
private readonly List<TReference> _modalRefs = [];

// The service this container attached to when it initialized, which is the one it detaches from on dispose
// whatever ModalService answers by then.
Expand All @@ -22,6 +23,13 @@ public abstract class BitModalContainerBase<TReference, TParameters> : Component
private TParameters? _lastModalParameters;
private readonly Dictionary<TReference, TParameters?> _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<TReference, int> _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<TReference, BitModalExit> _exits = [];
Expand Down Expand Up @@ -79,6 +87,12 @@ public abstract class BitModalContainerBase<TReference, TParameters> : Component
/// </summary>
protected virtual bool? GetCloseOnNavigation(TReference modalRef) => null;

/// <summary>
/// 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.
/// </summary>
protected IReadOnlyList<TReference> ModalReferences => _modalRefs;



/// <summary>
Expand All @@ -93,6 +107,7 @@ public abstract class BitModalContainerBase<TReference, TParameters> : Component
{
_lastModalParameters = ModalParameters;
_mergedParametersCache.Clear();
_refreshes++;
}

if (_mergedParametersCache.TryGetValue(modalRef, out var merged)) return merged;
Expand All @@ -114,6 +129,7 @@ public Task Refresh()
return InvokeAsync(() =>
{
_mergedParametersCache.Clear();
_refreshes++;
StateHasChanged();
});
}
Expand All @@ -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();
});
}
Expand All @@ -148,7 +172,7 @@ internal IReadOnlyList<TReference> GetOpenModals()
/// What the modal rendered for the given reference reports its exit animation played through; see
/// <see cref="BitModalExit"/>.
/// </summary>
private protected BitModalExit GetExit(TReference modalRef)
private BitModalExit GetExit(TReference modalRef)
{
if (_exits.TryGetValue(modalRef, out var exit)) return exit;

Expand All @@ -162,6 +186,76 @@ private protected BitModalExit GetExit(TReference modalRef)
return exit;
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// <br/>
/// 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.
/// </remarks>
protected RenderFragment RenderModals() => builder =>
{
foreach (var modalRef in _modalRefs)
{
BuildModal(builder, modalRef, key: true);
}
};

/// <summary>
/// Renders every modal this container holds the way <see cref="RenderModals()"/> does, each inside markup of the
/// container's own: the template is rendered once per modal, and renders the modal itself through
/// <see cref="BitModalContainerItem{TReference}.Modal"/>.
/// <code>
/// @(RenderModals(item =&gt; @&lt;div class="modal-frame"&gt;@item.Modal&lt;/div&gt;))
/// </code>
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
protected RenderFragment RenderModals(RenderFragment<BitModalContainerItem<TReference>> template)
{
ArgumentNullException.ThrowIfNull(template);

return builder =>
{
foreach (var modalRef in _modalRefs)
{
var item = new BitModalContainerItem<TReference>(modalRef, modalBuilder => BuildModal(modalBuilder, modalRef, key: false));

builder.OpenComponent<BitModalContainerSlot>(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<BitModalContainerEntry<TReference, TParameters>>(0);
if (key)
{
builder.SetKey(modalRef.Id);
}
builder.AddComponentParameter(1, BitModalContainerEntry<TReference, TParameters>.ReferenceName, modalRef);
builder.AddComponentParameter(2, BitModalContainerEntry<TReference, TParameters>.ParametersName, GetMergedParameters(modalRef));
builder.AddComponentParameter(3, BitModalContainerEntry<TReference, TParameters>.ExitName, GetExit(modalRef));
builder.AddComponentParameter(4, BitModalContainerEntry<TReference, TParameters>.VersionName, _refreshes + _modalRefreshes.GetValueOrDefault(modalRef));
builder.CloseComponent();
}

/// <summary>
/// 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
Expand Down Expand Up @@ -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)
Expand All @@ -285,6 +380,7 @@ private void Remove(TReference modalRef)
{
_modalRefs.Remove(modalRef);
_mergedParametersCache.Remove(modalRef);
_modalRefreshes.Remove(modalRef);
_exits.Remove(modalRef);
_leaving.Remove(modalRef);

Expand Down Expand Up @@ -403,6 +499,7 @@ protected virtual void Dispose(bool disposing)

_modalRefs.Clear();
_mergedParametersCache.Clear();
_modalRefreshes.Clear();
_exits.Clear();
_leaving.Clear();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using Microsoft.AspNetCore.Components.Rendering;

namespace Bit.BlazorUI;

/// <summary>
/// One modal of a container, with everything it expects from its container cascaded to it: its reference, what
/// it reports its exit through (<see cref="BitModalExit"/>) and its merged parameters.
/// </summary>
/// <remarks>
/// 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, <c>IsClosed</c>) stays current.
/// </remarks>
internal sealed class BitModalContainerEntry<TReference, TParameters> : IComponent
where TReference : BitModalReferenceBase<TReference, TParameters>
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<TReference>(ReferenceName)!;
var merged = parameters.GetValueOrDefault<TParameters?>(ParametersName);
var exit = parameters.GetValueOrDefault<BitModalExit>(ExitName)!;
var version = parameters.GetValueOrDefault<int>(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<CascadingValue<TReference>>(0);
builder.AddComponentParameter(1, nameof(CascadingValue<TReference>.Value), _reference);
builder.AddComponentParameter(2, nameof(CascadingValue<TReference>.ChildContent), (RenderFragment)(exitBuilder =>
{
exitBuilder.OpenComponent<CascadingValue<BitModalExit>>(0);
exitBuilder.AddComponentParameter(1, nameof(CascadingValue<BitModalExit>.Name), BitModalExit.CascadingName);
exitBuilder.AddComponentParameter(2, nameof(CascadingValue<BitModalExit>.Value), _exit);
exitBuilder.AddComponentParameter(3, nameof(CascadingValue<BitModalExit>.IsFixed), true);
exitBuilder.AddComponentParameter(4, nameof(CascadingValue<BitModalExit>.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<CascadingValue<TParameters?>>(0);
parametersBuilder.AddComponentParameter(1, nameof(CascadingValue<TParameters?>.Value), _parameters);
parametersBuilder.AddComponentParameter(2, nameof(CascadingValue<TParameters?>.ChildContent), _reference.Modal);
parametersBuilder.CloseComponent();
}));
exitBuilder.CloseComponent();
}));
builder.CloseComponent();
}
}
Comment thread
msynk marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Bit.BlazorUI;

/// <summary>
/// One modal of a container that puts markup of its own around each modal it renders: what the template handed to
/// <see cref="BitModalContainerBase{TReference, TParameters}.RenderModals(RenderFragment{BitModalContainerItem{TReference}})"/>
/// is rendered with, once per modal.
/// </summary>
/// <typeparam name="TReference">The modal reference type of the container.</typeparam>
public sealed class BitModalContainerItem<TReference> where TReference : class
{
internal BitModalContainerItem(TReference reference, RenderFragment modal)
{
Reference = reference;
Modal = modal;
}



/// <summary>
/// The reference of the modal - closed while it plays its exit animation, which is the only time a container
/// holds a closed one.
/// </summary>
public TReference Reference { get; }

/// <summary>
/// The modal itself, with everything it expects from its container: render it once, where it goes.
/// </summary>
public RenderFragment Modal { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Bit.BlazorUI;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <see cref="BitModalContainerEntry{TReference, TParameters}"/>, which renders only when its
/// own modal changed.
/// </remarks>
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<RenderFragment>(ChildContentName);

_renderHandle.Render(childContent ?? (_ => { }));

return Task.CompletedTask;
}
}
Loading
Loading