diff --git a/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor b/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor index e4c22b1079..dd2092acea 100644 --- a/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor +++ b/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor @@ -4,17 +4,30 @@ Icon="@Icon" IconColor="@IconColor" StartIcon="@StartIcon" + EndIcon="@EndIcon" Label="@Label" - AriaLabel="@AriaLabel" + AriaLabel="@GetAriaLabel()" Color="@Color" Variant="@Variant" Size="@Size" Dense="@Dense" + FullWidth="@FullWidth" + MaxHeight="@MaxHeight" AnchorOrigin="@AnchorOrigin" TransformOrigin="@TransformOrigin" + ActivationEvent="@ActivationEvent" + PositionAtCursor="@PositionAtCursor" + PopoverFixed="@PopoverFixed" + RelativeWidth="@RelativeWidth" + LockScroll="@LockScroll" + Ripple="@Ripple" + DropShadow="@DropShadow" Disabled="@Disabled" Class="@Class" Style="@Style" + ListClass="@ListClass" + PopoverClass="@PopoverClass" + UserAttributes="@UserAttributes" ActivatorContent="@ActivatorContent"> diff --git a/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor.cs b/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor.cs index a05b8f7c21..7a4b1f3ed3 100644 --- a/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor.cs +++ b/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenu.razor.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Localization.Resources.AbpUi; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; using MudBlazor; namespace Volo.Abp.MudBlazorUI.Components; @@ -12,6 +16,9 @@ public partial class AbpMudActionMenu : ComponentBase { protected MudMenu? _menu; + [Inject] + protected IStringLocalizer UiLocalizer { get; set; } = default!; + [Parameter] public string? Icon { get; set; } @@ -21,9 +28,16 @@ public partial class AbpMudActionMenu : ComponentBase [Parameter] public string? StartIcon { get; set; } + [Parameter] + public string? EndIcon { get; set; } + [Parameter] public string? Label { get; set; } + /// + /// The accessible name of the activator. An activator without a falls back + /// to the localized "Actions" text. + /// [Parameter] public string? AriaLabel { get; set; } @@ -39,12 +53,39 @@ public partial class AbpMudActionMenu : ComponentBase [Parameter] public bool Dense { get; set; } + [Parameter] + public bool FullWidth { get; set; } + + [Parameter] + public int? MaxHeight { get; set; } + [Parameter] public Origin? AnchorOrigin { get; set; } [Parameter] public Origin TransformOrigin { get; set; } = Origin.TopLeft; + [Parameter] + public MouseEvent ActivationEvent { get; set; } = MouseEvent.LeftClick; + + [Parameter] + public bool PositionAtCursor { get; set; } + + [Parameter] + public bool PopoverFixed { get; set; } + + [Parameter] + public DropdownWidth RelativeWidth { get; set; } = DropdownWidth.Ignore; + + [Parameter] + public bool LockScroll { get; set; } + + [Parameter] + public bool Ripple { get; set; } = true; + + [Parameter] + public bool DropShadow { get; set; } = true; + [Parameter] public bool Disabled { get; set; } @@ -54,21 +95,39 @@ public partial class AbpMudActionMenu : ComponentBase [Parameter] public string? Style { get; set; } + [Parameter] + public string? ListClass { get; set; } + + [Parameter] + public string? PopoverClass { get; set; } + /// - /// Replaces the default activator button. The menu is opened through the given - /// , same as . + /// Never null, because MudBlazor reads it without a null check while rendering. /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary UserAttributes { get; set; } = new(); + [Parameter] public RenderFragment? ActivatorContent { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; } + protected virtual string? GetAriaLabel() + { + if (!AriaLabel.IsNullOrEmpty()) + { + return AriaLabel; + } + + // A label is already the accessible name, so overriding it would hide the visible text. + return Label.IsNullOrEmpty() ? UiLocalizer["Actions"].Value : null; + } + /// - /// Closes the menu and returns the focus to its activator. - /// calls this before running its handler: MudBlazor restores - /// the focus while closing the menu, and doing that after the handler opened a dialog would take - /// the focus back out of that dialog. + /// Closes the whole menu hierarchy. calls this before running + /// its handler, because MudBlazor restores the focus to the activator while closing and that + /// would take the focus out of a dialog the handler opened. /// public virtual async Task CloseAsync() { diff --git a/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenuItem.razor b/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenuItem.razor index f2bd4d56d0..53b2064860 100644 --- a/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenuItem.razor +++ b/framework/src/Volo.Abp.MudBlazorUI/Components/AbpMudActionMenuItem.razor @@ -1,6 +1,6 @@ @using MudBlazor - -/// An item of an . It closes the menu before running -/// , so a dialog opened by the handler keeps the focus. -/// Outside an it behaves like a plain . +/// A menu item that closes its menu before running , so a dialog opened by +/// the handler keeps the focus. Works inside an and inside a plain +/// . /// public partial class AbpMudActionMenuItem : ComponentBase { [CascadingParameter] protected AbpMudActionMenu? ParentMenu { get; set; } + [CascadingParameter] + protected MudMenu? ParentMudMenu { get; set; } + + /// + /// Whether this item can close the menu itself. When it cannot, MudBlazor keeps closing it. + /// + protected virtual bool ControlsMenu => ParentMenu != null || ParentMudMenu != null; + [Parameter] public EventCallback OnClick { get; set; } @@ -43,8 +51,11 @@ public partial class AbpMudActionMenuItem : ComponentBase [Parameter] public string? Class { get; set; } + /// + /// Never null, because MudBlazor reads it without a null check while rendering. + /// [Parameter(CaptureUnmatchedValues = true)] - public Dictionary? UserAttributes { get; set; } + public Dictionary UserAttributes { get; set; } = new(); [Parameter] public RenderFragment? ChildContent { get; set; } @@ -55,6 +66,10 @@ public partial class AbpMudActionMenuItem : ComponentBase { await ParentMenu.CloseAsync(); } + else if (ParentMudMenu != null) + { + await ParentMudMenu.CloseAllMenusAsync(); + } await OnClick.InvokeAsync(args); } diff --git a/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor b/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor index 6b542ef6e5..a718e6fadb 100644 --- a/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor +++ b/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor @@ -5,7 +5,7 @@ @using Volo.Abp.FeatureManagement.Localization @inherits AbpFeatureManagementComponentBase - + @L["Features"]@ProviderKeyDisplayName @@ -18,10 +18,11 @@ { - @foreach (var group in Groups) - { - - + @{ + @* The features come from the definitions, so there is no known input to put + AutoFocus on. The first panel gets a container that takes the focus instead. *@ + RenderFragment renderGroup = group => + @ @group.DisplayName @foreach (var feature in group.Features) { @@ -68,7 +69,25 @@ } } - + ; + } + @for (var groupIndex = 0; groupIndex < Groups.Count; groupIndex++) + { + var group = Groups[groupIndex]; + @* MudTabs renders the panel content later, when the loop variable is already past + the last index. *@ + var isFirstGroup = groupIndex == 0; + + @if (isFirstGroup) + { +
+ @renderGroup(group) +
+ } + else + { + @renderGroup(group) + }
}
diff --git a/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor.cs b/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor.cs index c913b05f3c..f75adfafc4 100644 --- a/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor.cs +++ b/modules/feature-management/src/Volo.Abp.FeatureManagement.Blazor.MudBlazor/Components/FeatureManagementModal.razor.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; +using Microsoft.JSInterop; using Microsoft.Extensions.Options; using MudBlazor; using Volo.Abp.AspNetCore.Components.Messages; @@ -44,6 +45,14 @@ public partial class FeatureManagementModal protected Dictionary SelectionStringValues = new(); + protected const int MaxFocusRenderCount = 5; + + protected ElementReference _firstGroupContainer; + + protected bool _shouldFocusFirstGroup; + + protected int _focusRenderCount; + public virtual async Task OpenAsync(string providerName, string? providerKey = null, string? providerKeyDisplayName = null) { try @@ -89,6 +98,11 @@ public partial class FeatureManagementModal } _isVisible = true; + // The previous reference points to an element that is gone, and it would be taken for a + // bound container on the next render. + _firstGroupContainer = default; + _shouldFocusFirstGroup = Groups.Any(); + _focusRenderCount = 0; await InvokeAsync(StateHasChanged); } catch (Exception ex) @@ -97,6 +111,49 @@ public partial class FeatureManagementModal } } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (!_shouldFocusFirstGroup) + { + return; + } + + if (!_isVisible) + { + _shouldFocusFirstGroup = false; + return; + } + + // The dialog provider renders the content in a later batch, so the container is not bound yet + // on the render that makes the dialog visible. Rendering again brings this method back. + if (_firstGroupContainer.Id.IsNullOrEmpty()) + { + if (_focusRenderCount++ < MaxFocusRenderCount) + { + await InvokeAsync(StateHasChanged); + } + else + { + _shouldFocusFirstGroup = false; + } + + return; + } + + _shouldFocusFirstGroup = false; + + try + { + await _firstGroupContainer.MudFocusFirstAsync(); + } + catch (JSException) + { + // The dialog was closed before the focus call reached the element. + } + } + public virtual Task CloseModal() { _isVisible = false;