fix(ui): templates root-menu Escape + double-menu guard + reorder disabled tests (#258)
- Root context menu now has tabindex/focus + Escape-key close (OnRootMenuKeyDown) mirroring the node menu - Opening root menu calls _tree.DismissNodeContextMenu(); opening node menu fires OnNodeContextMenuOpened → DismissRootContextMenu so only one menu is ever visible - Add FolderContextMenu_MoveUp_IsDisabled_OnFirstSibling and FolderContextMenu_MoveDown_IsDisabled_OnLastSibling bUnit tests
This commit is contained in:
@@ -81,7 +81,8 @@
|
|||||||
SelectionMode="TreeViewSelectionMode.Single"
|
SelectionMode="TreeViewSelectionMode.Single"
|
||||||
ExtraTemplateChildren="BuildCompositionLeavesFor"
|
ExtraTemplateChildren="BuildCompositionLeavesFor"
|
||||||
Filter="@_searchText"
|
Filter="@_searchText"
|
||||||
StorageKey="templates-tree">
|
StorageKey="templates-tree"
|
||||||
|
OnNodeContextMenuOpened="DismissRootContextMenu">
|
||||||
<NodeContent Context="node">
|
<NodeContent Context="node">
|
||||||
@RenderNodeLabel(node)
|
@RenderNodeLabel(node)
|
||||||
</NodeContent>
|
</NodeContent>
|
||||||
@@ -98,8 +99,10 @@
|
|||||||
{
|
{
|
||||||
<div class="tv-ctx-overlay" @onclick="DismissRootContextMenu"
|
<div class="tv-ctx-overlay" @onclick="DismissRootContextMenu"
|
||||||
style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
||||||
<div class="dropdown-menu show tv-root-menu" @onclick="DismissRootContextMenu"
|
<div class="dropdown-menu show tv-root-menu" tabindex="-1" @ref="_rootMenuRef"
|
||||||
style="position:fixed;top:@(_rootMenuY)px;left:@(_rootMenuX)px;z-index:1050;">
|
@onclick="DismissRootContextMenu"
|
||||||
|
@onkeydown="OnRootMenuKeyDown"
|
||||||
|
style="position:fixed;top:@(_rootMenuY)px;left:@(_rootMenuX)px;z-index:1050;outline:none;">
|
||||||
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(null)">New Folder</button>
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(null)">New Folder</button>
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,16 +418,41 @@
|
|||||||
private bool _showRootMenu;
|
private bool _showRootMenu;
|
||||||
private double _rootMenuX;
|
private double _rootMenuX;
|
||||||
private double _rootMenuY;
|
private double _rootMenuY;
|
||||||
|
private bool _rootMenuNeedsFocus;
|
||||||
|
private ElementReference _rootMenuRef;
|
||||||
|
|
||||||
private void OpenRootContextMenu(MouseEventArgs e)
|
private void OpenRootContextMenu(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
|
// Guard: dismiss any open node context menu first so both menus are
|
||||||
|
// never visible simultaneously (double-menu flash fix, #258).
|
||||||
|
_tree.DismissNodeContextMenu();
|
||||||
|
|
||||||
_rootMenuX = e.ClientX;
|
_rootMenuX = e.ClientX;
|
||||||
_rootMenuY = e.ClientY;
|
_rootMenuY = e.ClientY;
|
||||||
_showRootMenu = true;
|
_showRootMenu = true;
|
||||||
|
_rootMenuNeedsFocus = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DismissRootContextMenu() => _showRootMenu = false;
|
private void DismissRootContextMenu() => _showRootMenu = false;
|
||||||
|
|
||||||
|
private void OnRootMenuKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Escape")
|
||||||
|
DismissRootContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_rootMenuNeedsFocus && _showRootMenu)
|
||||||
|
{
|
||||||
|
_rootMenuNeedsFocus = false;
|
||||||
|
try { await _rootMenuRef.FocusAsync(); }
|
||||||
|
catch (Microsoft.JSInterop.JSException) { }
|
||||||
|
catch (Microsoft.JSInterop.JSDisconnectedException) { }
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rename folder dialog: opened via IDialogService.ShowAsync. The body seeds the
|
// Rename folder dialog: opened via IDialogService.ShowAsync. The body seeds the
|
||||||
// input from the current name and returns the trimmed new name on Save, or null on
|
// input from the current name and returns the trimmed new name on Save, or null on
|
||||||
// Cancel; server guard failures surface via a toast.
|
// Cancel; server guard failures surface via a toast.
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
SelectedKeys="SelectedKeys"
|
SelectedKeys="SelectedKeys"
|
||||||
SelectedKeysChanged="SelectedKeysChanged"
|
SelectedKeysChanged="SelectedKeysChanged"
|
||||||
InitiallyExpanded="@(_initiallyExpanded)"
|
InitiallyExpanded="@(_initiallyExpanded)"
|
||||||
StorageKey="@StorageKey">
|
StorageKey="@StorageKey"
|
||||||
|
OnNodeContextMenuOpened="OnNodeContextMenuOpened">
|
||||||
<NodeContent Context="node">
|
<NodeContent Context="node">
|
||||||
@if (NodeContent != null)
|
@if (NodeContent != null)
|
||||||
{
|
{
|
||||||
@@ -72,6 +73,8 @@
|
|||||||
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
||||||
[Parameter] public EventCallback<TemplateTreeNode> OnNodeClick { get; set; }
|
[Parameter] public EventCallback<TemplateTreeNode> OnNodeClick { get; set; }
|
||||||
[Parameter] public string? StorageKey { get; set; }
|
[Parameter] public string? StorageKey { get; set; }
|
||||||
|
/// <summary>Raised when a node context menu opens; forwarded from the inner TreeView.</summary>
|
||||||
|
[Parameter] public EventCallback OnNodeContextMenuOpened { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional: caller-supplied extra leaves to nest under a template. Used by
|
/// Optional: caller-supplied extra leaves to nest under a template. Used by
|
||||||
@@ -225,4 +228,10 @@
|
|||||||
|
|
||||||
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
||||||
public void CollapseAll() => _tree?.CollapseAll();
|
public void CollapseAll() => _tree?.CollapseAll();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Close any open node context menu. Called by the host page when it opens the
|
||||||
|
/// root context menu so both menus are never visible simultaneously.
|
||||||
|
/// </summary>
|
||||||
|
public void DismissNodeContextMenu() => _tree?.DismissContextMenu();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ else
|
|||||||
[Parameter, EditorRequired] public RenderFragment<TItem> NodeContent { get; set; } = default!;
|
[Parameter, EditorRequired] public RenderFragment<TItem> NodeContent { get; set; } = default!;
|
||||||
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
||||||
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
|
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
|
||||||
|
/// <summary>Raised when a node context menu opens, so the host page can dismiss any sibling menus.</summary>
|
||||||
|
[Parameter] public EventCallback OnNodeContextMenuOpened { get; set; }
|
||||||
[Parameter] public int IndentPx { get; set; } = 24;
|
[Parameter] public int IndentPx { get; set; } = 24;
|
||||||
[Parameter] public bool ShowGuideLines { get; set; } = true;
|
[Parameter] public bool ShowGuideLines { get; set; } = true;
|
||||||
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
|
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
|
||||||
@@ -315,7 +317,7 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnContextMenu(MouseEventArgs e, TItem item)
|
private async Task OnContextMenu(MouseEventArgs e, TItem item)
|
||||||
{
|
{
|
||||||
if (ContextMenu == null) return;
|
if (ContextMenu == null) return;
|
||||||
|
|
||||||
@@ -324,9 +326,13 @@ else
|
|||||||
_contextMenuY = e.ClientY;
|
_contextMenuY = e.ClientY;
|
||||||
_showContextMenu = true;
|
_showContextMenu = true;
|
||||||
_contextMenuNeedsFocus = true;
|
_contextMenuNeedsFocus = true;
|
||||||
|
|
||||||
|
if (OnNodeContextMenuOpened.HasDelegate)
|
||||||
|
await OnNodeContextMenuOpened.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DismissContextMenu()
|
/// <summary>Programmatically close the node context menu (e.g. so a sibling menu can take over).</summary>
|
||||||
|
public void DismissContextMenu()
|
||||||
{
|
{
|
||||||
_showContextMenu = false;
|
_showContextMenu = false;
|
||||||
_contextMenuItem = default;
|
_contextMenuItem = default;
|
||||||
|
|||||||
@@ -280,6 +280,36 @@ public class TemplatesPageTests : BunitContext
|
|||||||
_repo.Received(2).GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
_repo.Received(2).GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FolderContextMenu_MoveUp_IsDisabled_OnFirstSibling()
|
||||||
|
{
|
||||||
|
// Alpha is the first sibling (SortOrder 0) — Move up must be disabled.
|
||||||
|
SeedTwoRootSiblings();
|
||||||
|
|
||||||
|
var cut = Render<TemplatesPage>();
|
||||||
|
|
||||||
|
var menu = OpenFolderContextMenu(cut, "Alpha");
|
||||||
|
var moveUpBtn = menu.QuerySelectorAll("button.dropdown-item")
|
||||||
|
.First(b => b.TextContent.Trim() == "Move up");
|
||||||
|
|
||||||
|
Assert.True(moveUpBtn.HasAttribute("disabled"), "Move up should be disabled for the first sibling");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FolderContextMenu_MoveDown_IsDisabled_OnLastSibling()
|
||||||
|
{
|
||||||
|
// Beta is the last sibling (SortOrder 1) — Move down must be disabled.
|
||||||
|
SeedTwoRootSiblings();
|
||||||
|
|
||||||
|
var cut = Render<TemplatesPage>();
|
||||||
|
|
||||||
|
var menu = OpenFolderContextMenu(cut, "Beta");
|
||||||
|
var moveDownBtn = menu.QuerySelectorAll("button.dropdown-item")
|
||||||
|
.First(b => b.TextContent.Trim() == "Move down");
|
||||||
|
|
||||||
|
Assert.True(moveDownBtn.HasAttribute("disabled"), "Move down should be disabled for the last sibling");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RootContextMenu_OffersNewFolderAndNewTemplate()
|
public void RootContextMenu_OffersNewFolderAndNewTemplate()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user