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;
}