Browse Source

Fix keyboard interaction for ContextMenu.

Steven Kirk 6 years ago
parent
commit
4a5e11f6aa

+ 7 - 0
src/Avalonia.Controls/ContextMenu.cs

@@ -24,6 +24,7 @@ namespace Avalonia.Controls
         /// Initializes a new instance of the <see cref="ContextMenu"/> class.
         /// </summary>
         public ContextMenu()
+            : this(new DefaultMenuInteractionHandler(true))
         {
         }
 
@@ -99,6 +100,7 @@ namespace Avalonia.Controls
                     ObeyScreenEdges = true
                 };
 
+                _popup.Opened += PopupOpened;
                 _popup.Closed += PopupClosed;
             }
 
@@ -127,6 +129,11 @@ namespace Avalonia.Controls
             return new MenuItemContainerGenerator(this);
         }
 
+        private void PopupOpened(object sender, EventArgs e)
+        {
+            Focus();
+        }
+
         private void PopupClosed(object sender, EventArgs e)
         {
             var contextMenu = (sender as Popup)?.Child as ContextMenu;

+ 1 - 1
src/Avalonia.Controls/Menu.cs

@@ -11,7 +11,7 @@ namespace Avalonia.Controls
     /// <summary>
     /// A top-level menu control.
     /// </summary>
-    public class Menu : MenuBase, IFocusScope, IMainMenu
+    public class Menu : MenuBase, IMainMenu
     {
         private static readonly ITemplate<IPanel> DefaultPanel =
             new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });

+ 2 - 3
src/Avalonia.Controls/MenuBase.cs

@@ -17,7 +17,7 @@ namespace Avalonia.Controls
     /// <summary>
     /// Base class for menu controls.
     /// </summary>
-    public abstract class MenuBase : SelectingItemsControl, IMenu
+    public abstract class MenuBase : SelectingItemsControl, IFocusScope, IMenu
     {
         /// <summary>
         /// Defines the <see cref="IsOpen"/> property.
@@ -46,8 +46,7 @@ namespace Avalonia.Controls
         /// </summary>
         public MenuBase()
         {
-            InteractionHandler = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ?? 
-                new DefaultMenuInteractionHandler();
+            InteractionHandler = new DefaultMenuInteractionHandler(false);
         }
 
         /// <summary>

+ 47 - 33
src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs

@@ -13,18 +13,21 @@ namespace Avalonia.Controls.Platform
     /// </summary>
     public class DefaultMenuInteractionHandler : IMenuInteractionHandler
     {
+        private readonly bool _isContextMenu;
         private IDisposable _inputManagerSubscription;
         private IRenderRoot _root;
 
-        public DefaultMenuInteractionHandler()
-            : this(Input.InputManager.Instance, DefaultDelayRun)
+        public DefaultMenuInteractionHandler(bool isContextMenu)
+            : this(isContextMenu, Input.InputManager.Instance, DefaultDelayRun)
         {
         }
 
         public DefaultMenuInteractionHandler(
+            bool isContextMenu,
             IInputManager inputManager,
             Action<Action, TimeSpan> delayRun)
         {
+            _isContextMenu = isContextMenu;
             InputManager = inputManager;
             DelayRun = delayRun;
         }
@@ -59,7 +62,7 @@ namespace Avalonia.Controls.Platform
                 window.Deactivated += WindowDeactivated;
             }
 
-            _inputManagerSubscription = InputManager.Process.Subscribe(RawInput);
+            _inputManagerSubscription = InputManager?.Process.Subscribe(RawInput);
         }
 
         public virtual void Detach(IMenu menu)
@@ -125,23 +128,16 @@ namespace Avalonia.Controls.Platform
 
         protected internal virtual void KeyDown(object sender, KeyEventArgs e)
         {
-            var item = GetMenuItem(e.Source as IControl);
-
-            if (item != null)
-            {
-                KeyDown(item, e);
-            }
+            KeyDown(GetMenuItem(e.Source as IControl), e);
         }
 
         protected internal virtual void KeyDown(IMenuItem item, KeyEventArgs e)
         {
-            Contract.Requires<ArgumentNullException>(item != null);
-
             switch (e.Key)
             {
                 case Key.Up:
                 case Key.Down:
-                    if (item.IsTopLevel)
+                    if (item?.IsTopLevel == true)
                     {
                         if (item.HasSubMenu && !item.IsSubMenuOpen)
                         {
@@ -156,7 +152,7 @@ namespace Avalonia.Controls.Platform
                     break;
 
                 case Key.Left:
-                    if (item.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen)
+                    if (item?.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen)
                     {
                         parent.Close();
                         parent.Focus();
@@ -169,7 +165,7 @@ namespace Avalonia.Controls.Platform
                     break;
 
                 case Key.Right:
-                    if (!item.IsTopLevel && item.HasSubMenu)
+                    if (item != null && !item.IsTopLevel && item.HasSubMenu)
                     {
                         Open(item, true);
                         e.Handled = true;
@@ -181,47 +177,65 @@ namespace Avalonia.Controls.Platform
                     break;
 
                 case Key.Enter:
-                    if (!item.HasSubMenu)
+                    if (item != null)
                     {
-                        Click(item);
-                    }
-                    else
-                    {
-                        Open(item, true);
-                    }
+                        if (!item.HasSubMenu)
+                        {
+                            Click(item);
+                        }
+                        else
+                        {
+                            Open(item, true);
+                        }
 
-                    e.Handled = true;
+                        e.Handled = true;
+                    }
                     break;
 
                 case Key.Escape:
-                    if (item.Parent != null)
+                    if (item?.Parent != null)
                     {
                         item.Parent.Close();
                         item.Parent.Focus();
-                        e.Handled = true;
                     }
+                    else
+                    {
+                        Menu.Close();
+                    }
+
+                    e.Handled = true;
                     break;
 
                 default:
                     var direction = e.Key.ToNavigationDirection();
 
-                    if (direction.HasValue && item.Parent?.MoveSelection(direction.Value, true) == true)
+                    if (direction.HasValue)
                     {
-                        // If the the parent is an IMenu which successfully moved its selection,
-                        // and the current menu is open then close the current menu and open the
-                        // new menu.
-                        if (item.IsSubMenuOpen && item.Parent is IMenu)
+                        if (item == null && _isContextMenu)
                         {
-                            item.Close();
-                            Open(item.Parent.SelectedItem, true);
+                            if (Menu.MoveSelection(direction.Value, true) == true)
+                            {
+                                e.Handled = true;
+                            }
+                        }
+                        else if (item.Parent?.MoveSelection(direction.Value, true) == true)
+                        {
+                            // If the the parent is an IMenu which successfully moved its selection,
+                            // and the current menu is open then close the current menu and open the
+                            // new menu.
+                            if (item.IsSubMenuOpen && item.Parent is IMenu)
+                            {
+                                item.Close();
+                                Open(item.Parent.SelectedItem, true);
+                            }
+                            e.Handled = true;
                         }
-                        e.Handled = true;
                     }
 
                     break;
             }
 
-            if (!e.Handled && item.Parent is IMenuItem parentItem)
+            if (!e.Handled && item?.Parent is IMenuItem parentItem)
             {
                 KeyDown(parentItem, e);
             }

+ 1 - 1
src/Avalonia.Input/InputElement.cs

@@ -375,7 +375,7 @@ namespace Avalonia.Input
         /// </summary>
         public void Focus()
         {
-            FocusManager.Instance.Focus(this);
+            FocusManager.Instance?.Focus(this);
         }
 
         /// <inheritdoc/>

+ 44 - 27
tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs

@@ -13,7 +13,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Up_Opens_MenuItem_With_SubMenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var e = new KeyEventArgs { Key = Key.Up, Source = item };
 
@@ -27,7 +27,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Down_Opens_MenuItem_With_SubMenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var e = new KeyEventArgs { Key = Key.Down, Source = item };
 
@@ -41,7 +41,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Right_Selects_Next_MenuItem()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Right, true) == true);
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
                 var e = new KeyEventArgs { Key = Key.Right, Source = item };
@@ -55,7 +55,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Left_Selects_Previous_MenuItem()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Left, true) == true);
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
                 var e = new KeyEventArgs { Key = Key.Left, Source = item };
@@ -69,7 +69,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Enter_On_Item_With_No_SubMenu_Causes_Click()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
                 var e = new KeyEventArgs { Key = Key.Enter, Source = item };
@@ -84,7 +84,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var e = new KeyEventArgs { Key = Key.Enter, Source = item };
@@ -99,7 +99,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Escape_Closes_Parent_Menu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu);
                 var e = new KeyEventArgs { Key = Key.Escape, Source = item };
@@ -113,7 +113,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerEnter_Opens_Item_When_Old_Item_Is_Open()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = new Mock<IMenu>();
                 var item = Mock.Of<IMenuItem>(x =>
                     x.IsSubMenuOpen == true &&
@@ -141,7 +141,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerLeave_Deselects_Item_When_Menu_Not_Open()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = new Mock<IMenu>();
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu.Object);
                 var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
@@ -156,7 +156,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerLeave_Doesnt_Deselect_Item_When_Menu_Open()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = new Mock<IMenu>();
                 var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.Parent == menu.Object);
                 var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
@@ -175,7 +175,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Up_Selects_Previous_MenuItem()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
                 var e = new KeyEventArgs { Key = Key.Up, Source = item };
@@ -189,7 +189,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Down_Selects_Next_MenuItem()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
                 var e = new KeyEventArgs { Key = Key.Down, Source = item };
@@ -203,7 +203,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Left_Closes_Parent_SubMenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var parentItem = Mock.Of<IMenuItem>(x => x.HasSubMenu == true && x.IsSubMenuOpen == true);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
                 var e = new KeyEventArgs { Key = Key.Left, Source = item };
@@ -218,7 +218,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Right_With_SubMenu_Items_Opens_SubMenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
                 var e = new KeyEventArgs { Key = Key.Right, Source = item };
@@ -233,7 +233,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Right_On_TopLevel_Child_Navigates_TopLevel_Selection()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = new Mock<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => 
                     x.IsSubMenuOpen == true &&
@@ -263,7 +263,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Enter_On_Item_With_No_SubMenu_Causes_Click()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@@ -279,7 +279,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
                 var e = new KeyEventArgs { Key = Key.Enter, Source = item };
@@ -294,7 +294,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void Escape_Closes_Parent_MenuItem()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
                 var e = new KeyEventArgs { Key = Key.Escape, Source = item };
@@ -309,7 +309,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerEnter_Selects_Item()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@@ -325,7 +325,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             public void PointerEnter_Opens_Submenu_After_Delay()
             {
                 var timer = new TestTimer();
-                var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
+                var target = new DefaultMenuInteractionHandler(false, null, timer.RunOnce);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
@@ -344,7 +344,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             public void PointerEnter_Closes_Sibling_Submenu_After_Delay()
             {
                 var timer = new TestTimer();
-                var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
+                var target = new DefaultMenuInteractionHandler(false, null, timer.RunOnce);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@@ -365,7 +365,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerLeave_Deselects_Item()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@@ -381,7 +381,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerLeave_Doesnt_Deselect_Sibling()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@@ -398,7 +398,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsPointerOverSubMenu == true);
@@ -413,7 +413,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerReleased_On_Item_With_No_SubMenu_Causes_Click()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
@@ -430,7 +430,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             public void Selection_Is_Correct_When_Pointer_Temporarily_Exits_Item_To_Select_SubItem()
             {
                 var timer = new TestTimer();
-                var target = new DefaultMenuInteractionHandler(null, timer.RunOnce);
+                var target = new DefaultMenuInteractionHandler(false, null, timer.RunOnce);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
@@ -467,7 +467,7 @@ namespace Avalonia.Controls.UnitTests.Platform
             [Fact]
             public void PointerPressed_On_Item_With_SubMenu_Causes_Opens_Submenu()
             {
-                var target = new DefaultMenuInteractionHandler();
+                var target = new DefaultMenuInteractionHandler(false);
                 var menu = Mock.Of<IMenu>();
                 var parentItem = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true && x.Parent == menu);
                 var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true);
@@ -481,6 +481,23 @@ namespace Avalonia.Controls.UnitTests.Platform
             }
         }
 
+        public class ContextMenu
+        {
+            [Fact]
+            public void Down_Selects_Selects_First_MenuItem_When_No_Selection()
+            {
+                var target = new DefaultMenuInteractionHandler(true);
+                var contextMenu = Mock.Of<IMenu>(x => x.MoveSelection(NavigationDirection.Down, true) == true);
+                var e = new KeyEventArgs { Key = Key.Down, Source = contextMenu };
+
+                target.Attach(contextMenu);
+                target.KeyDown(contextMenu, e);
+
+                Mock.Get(contextMenu).Verify(x => x.MoveSelection(NavigationDirection.Down, true));
+                Assert.True(e.Handled);
+            }
+        }
+
         private class TestTimer
         {
             private Action _action;