diff --git a/src/MudBlazor.Analyzers.TestComponents/MudBlazor.Analyzers.TestComponents.csproj b/src/MudBlazor.Analyzers.TestComponents/MudBlazor.Analyzers.TestComponents.csproj index ba664c104801..c9a66a86ced0 100644 --- a/src/MudBlazor.Analyzers.TestComponents/MudBlazor.Analyzers.TestComponents.csproj +++ b/src/MudBlazor.Analyzers.TestComponents/MudBlazor.Analyzers.TestComponents.csproj @@ -8,6 +8,9 @@ + + + diff --git a/src/MudBlazor.Analyzers/MudBlazor.Analyzers.csproj b/src/MudBlazor.Analyzers/MudBlazor.Analyzers.csproj index dca630339324..56612da8a2de 100644 --- a/src/MudBlazor.Analyzers/MudBlazor.Analyzers.csproj +++ b/src/MudBlazor.Analyzers/MudBlazor.Analyzers.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/MudBlazor.UnitTests/Components/MenuTests.cs b/src/MudBlazor.UnitTests/Components/MenuTests.cs index 9528e2321221..5d55a744abe6 100644 --- a/src/MudBlazor.UnitTests/Components/MenuTests.cs +++ b/src/MudBlazor.UnitTests/Components/MenuTests.cs @@ -653,5 +653,179 @@ public void Menu_ButtonActivator() comp.Find("button.mud-icon-button-activator").Click(); provider.FindAll("div.mud-popover-open").Count.Should().Be(1); } + + [Test] + public async Task Menu_PointerEvents_ShowHide_WithDebounce() + { + // This method uses CatchAndLog to allow async events to run syncronously so we can test timing + // Set a predictable hover delay for testing + var hoverDelay = 300; + MudGlobal.MenuDefaults.HoverDelay = hoverDelay; + + var comp = Context.RenderComponent(); + + // Open the main menu first + comp.Find("button:contains('1')").Click(); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Main menu should be open"); + + // 1. Test SHOW debounce behavior + // Trigger pointer enter on submenu item + var menuItem = comp.Find("div.mud-menu:contains('1.3')"); + + // Immediately after hover, submenu should not be visible yet (debounce in effect) + menuItem.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Submenu should not open immediately"); + + // After the hover delay, submenu should become visible + await Task.Delay(hoverDelay + 50); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should open after hover delay"); + + // 2. Test HIDE debounce behavior + + // Trigger pointer leave + menuItem.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + + // Immediately after leave, submenu should still be visible (hide debounce in effect) + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should remain open immediately after pointer leave"); + + // Wait less than the hide delay (which is 2x show delay) + await Task.Delay(hoverDelay + 50); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should still be open before hide delay completes"); + + // After the full hide delay (2x hover delay), submenu should close + await Task.Delay(hoverDelay + 50); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Submenu should close after full hide delay (2x hover delay)"); + } + + [Test] + public async Task Menu_PointerEvents_Cancellation() + { + // This method uses CatchAndLog to allow async events to run syncronously so we can test timing + // Set a predictable hover delay for testing + var hoverDelay = 300; + MudGlobal.MenuDefaults.HoverDelay = hoverDelay; + + var comp = Context.RenderComponent(); + + // Open the main menu first + comp.Find("button:contains('1')").Click(); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Main menu should be open"); + + // 1. Test cancellation of SHOW debounce + + var menus = comp.FindComponents(); + var menu = menus.FirstOrDefault(x => x.Instance.Label == "1.3").Instance; + menu.Should().NotBeNull(); + + // Start hover, then leave before debounce completes + menu.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menu._showDebouncer.Cancel(); + + // Wait for original show debounce to have completed + await Task.Delay(hoverDelay + 100); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, + "Submenu should not open because show debounce was cancelled by pointer leave"); + + // 2. Test cancellation of HIDE debounce + + // First, open the submenu properly, use await since we are testing this + await menu.PointerEnterAsync(new PointerEventArgs()); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should be open"); + + // Start leave sequence, but re-enter before hide completes + menu.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(hoverDelay); + menu._hideDebouncer.Cancel(); + + // Wait for what would have been the full hide delay + await Task.Delay((hoverDelay * 2) + 100); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, + "Submenu should still be open because hide debounce was cancelled by re-entering"); + } + + [Test] + public async Task Menu_PointerEvents_MultipleLevels() + { + // This method uses CatchAndLog to allow async events to run syncronously so we can test timing + // Set a predictable hover delay for testing + var hoverDelay = 300; + MudGlobal.MenuDefaults.HoverDelay = hoverDelay; + + var comp = Context.RenderComponent(); + + // Open the main menu first + comp.Find("button:contains('1')").Click(); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Main menu should be open"); + + // Open first level submenu + var menuItem1 = comp.Find("div.mud-menu:contains('1.3')"); + menuItem1.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(hoverDelay + 100); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "First level submenu should be open"); + + // Open second level submenu + var menuItem2 = comp.Find("div.mud-menu:contains('2.1')"); + menuItem2.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(hoverDelay + 100); + comp.FindAll("div.mud-popover-open").Count.Should().Be(3, "Second level submenu should be open"); + + // Leaving second level should close only that level after delay + menuItem2.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay((hoverDelay * 2) + 100); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, + "Second level should close but first level should remain open"); + + // Leaving first level should close it after delay + menuItem1.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay((hoverDelay * 2) + 100); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, + "First level should close but main menu should remain open"); + } + + [Test] + public async Task Menu_PointerEvents_RapidMovement() + { + // This method uses CatchAndLog to allow async events to run syncronously so we can test timing + // Set a predictable hover delay for testing + var hoverDelay = 300; + MudGlobal.MenuDefaults.HoverDelay = hoverDelay; + + var comp = Context.RenderComponent(); + + // Open the main menu first + comp.Find("button:contains('1')").Click(); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Main menu should be open"); + + var menuItem = comp.Find("div.mud-menu:contains('1.3')"); + + // Simulate rapid mouse movement: enter -> leave -> enter -> leave -> enter + menuItem.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menuItem.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menuItem.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menuItem.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menuItem.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + + // Final state should be "entering" so menu should open + await Task.Delay(hoverDelay + 50); + comp.FindAll("div.mud-popover-open").Count.Should().Be(2, + "Menu should open after rapid movement ending with pointer enter"); + + // Now rapid movement ending with leaving + menuItem.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menuItem.PointerEnterAsync(new PointerEventArgs()).CatchAndLog(); + await Task.Delay(50); + menuItem.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); + + // Final state should be "leaving" so menu should close + await Task.Delay((hoverDelay * 2) + 50); + comp.FindAll("div.mud-popover-open").Count.Should().Be(1, + "Menu should close after rapid movement ending with pointer leave"); + } } } diff --git a/src/MudBlazor/Components/Menu/MudMenu.razor b/src/MudBlazor/Components/Menu/MudMenu.razor index 059f7a908aec..b460fe96f4c6 100644 --- a/src/MudBlazor/Components/Menu/MudMenu.razor +++ b/src/MudBlazor/Components/Menu/MudMenu.razor @@ -69,29 +69,29 @@ } @* The portal has to include the cascading values inside because it's not able to teletransport the cascade *@ - @if (_openState.Value) + + + + @ChildContent + + + + @if (ParentMenu is null) { - - - - @ChildContent - - - - - } + + } diff --git a/src/MudBlazor/Components/Menu/MudMenu.razor.cs b/src/MudBlazor/Components/Menu/MudMenu.razor.cs index 1384cc2690b8..3f3c95404c10 100644 --- a/src/MudBlazor/Components/Menu/MudMenu.razor.cs +++ b/src/MudBlazor/Components/Menu/MudMenu.razor.cs @@ -7,6 +7,7 @@ using MudBlazor.Interfaces; using MudBlazor.State; using MudBlazor.Utilities; +using MudBlazor.Utilities.Debounce; namespace MudBlazor { @@ -22,11 +23,15 @@ public partial class MudMenu : MudComponentBase, IActivatable, IDisposable private (double Top, double Left) _openPosition; private bool _isPointerOver; private bool _isTransient; - private CancellationTokenSource? _hoverCts; - private CancellationTokenSource? _leaveCts; + internal DebounceDispatcher _showDebouncer; + internal DebounceDispatcher _hideDebouncer; public MudMenu() { + _showDebouncer = new DebounceDispatcher(MudGlobal.MenuDefaults.HoverDelay); + // double the delay for hiding a menu + _hideDebouncer = new DebounceDispatcher(MudGlobal.MenuDefaults.HoverDelay * 2); + using var registerScope = CreateRegisterScope(); _openState = registerScope.RegisterParameter(nameof(Open)) .WithParameter(() => Open) @@ -539,73 +544,56 @@ private bool IsHoverable(PointerEventArgs args) /// /// Handles the pointer entering either the activator or the menu list. /// - private async Task PointerEnterAsync(PointerEventArgs args) + internal async Task PointerEnterAsync(PointerEventArgs args) { _isPointerOver = true; - CancelPendingActions(); - if (!IsHoverable(args)) { return; } - if (MudGlobal.MenuDefaults.HoverDelay > 0) - { - _hoverCts = new(); + // Cancel any pending hide operation + _hideDebouncer.Cancel(); - try - { - // Wait a bit to allow the cursor to move over the activator if the user isn't trying to open it. - await Task.Delay(MudGlobal.MenuDefaults.HoverDelay, _hoverCts.Token); - } - catch (TaskCanceledException) + // Schedule the show operation with debouncing + await _showDebouncer.DebounceAsync(async () => + { + if (!_openState.Value) { - // Hover action was canceled. - return; + await OpenSubMenuAsync(args); } - } - - if (!_openState.Value) - { - await OpenSubMenuAsync(args); - } + }); } /// /// Handles the pointer leaving either the activator or the menu list. /// - private async Task PointerLeaveAsync(PointerEventArgs args) + internal async Task PointerLeaveAsync(PointerEventArgs args) { _isPointerOver = false; - - CancelPendingActions(); + var isSubmenu = ParentMenu is not null; + if (!isSubmenu && ActivationEvent != MouseEvent.MouseOver) + { + return; // main menu that doesn't use mouseover + } if (!_isTransient || !IsHoverable(args)) { return; } - if (MudGlobal.MenuDefaults.HoverDelay > 0) - { - _leaveCts = new(); + // Cancel any pending show operation + _showDebouncer.Cancel(); - try - { - // Wait a bit to allow the cursor to move from the activator to the items popover. - await Task.Delay(MudGlobal.MenuDefaults.HoverDelay, _leaveCts.Token); - } - catch (TaskCanceledException) + // Schedule the hide operation with debouncing + await _hideDebouncer.DebounceAsync(async () => + { + if (!HasPointerOver(this)) { - // Leave action was canceled. - return; + await CloseMenuAsync(); } - } - - if (!HasPointerOver(this)) - { - await CloseMenuAsync(); - } + }); } protected bool HasPointerOver(MudMenu menu) @@ -622,10 +610,8 @@ protected bool HasPointerOver(MudMenu menu) /// private void CancelPendingActions() { - // ReSharper disable MethodHasAsyncOverload - _leaveCts?.Cancel(); - _hoverCts?.Cancel(); - // ReSharper restore MethodHasAsyncOverload + _showDebouncer.Cancel(); + _hideDebouncer.Cancel(); } /// @@ -650,11 +636,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - _hoverCts?.Cancel(); - _hoverCts?.Dispose(); - - _leaveCts?.Cancel(); - _leaveCts?.Dispose(); + CancelPendingActions(); ParentMenu?.UnregisterChild(this); } diff --git a/src/MudBlazor/Services/MudGlobal.cs b/src/MudBlazor/Services/MudGlobal.cs index 72f67480f2dc..aa301c1ef521 100644 --- a/src/MudBlazor/Services/MudGlobal.cs +++ b/src/MudBlazor/Services/MudGlobal.cs @@ -78,7 +78,7 @@ public static class MenuDefaults { /// /// The time in milliseconds before a is activated by the cursor hovering over it - /// or before it is hidden after the cursor leaves the menu. + /// or 2x that time before it is hidden after the cursor leaves the menu. /// public static int HoverDelay { get; set; } = 300; }