Ver código fonte

Merge pull request #1812 from AvaloniaUI/fixes/directional-navigation

Fix Menu navigation
Steven Kirk 7 anos atrás
pai
commit
0166b2ec6a
33 arquivos alterados com 1883 adições e 1556 exclusões
  1. 2 1
      src/Avalonia.Controls/Canvas.cs
  2. 96 52
      src/Avalonia.Controls/ContextMenu.cs
  3. 21 0
      src/Avalonia.Controls/IMenu.cs
  4. 40 0
      src/Avalonia.Controls/IMenuElement.cs
  5. 41 0
      src/Avalonia.Controls/IMenuItem.cs
  6. 83 4
      src/Avalonia.Controls/ItemsControl.cs
  7. 112 138
      src/Avalonia.Controls/Menu.cs
  8. 131 170
      src/Avalonia.Controls/MenuItem.cs
  9. 459 0
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  10. 22 0
      src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs
  11. 1 8
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  12. 36 0
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  13. 48 10
      src/Avalonia.Controls/StackPanel.cs
  14. 86 0
      src/Avalonia.Controls/TreeView.cs
  15. 2 5
      src/Avalonia.Controls/TreeViewItem.cs
  16. 3 2
      src/Avalonia.Controls/WrapPanel.cs
  17. 29 8
      src/Avalonia.Input/AccessKeyHandler.cs
  18. 1 1
      src/Avalonia.Input/IInputElement.cs
  19. 7 0
      src/Avalonia.Input/IMainMenu.cs
  20. 2 1
      src/Avalonia.Input/INavigableContainer.cs
  21. 1 1
      src/Avalonia.Input/InputElement.cs
  22. 0 33
      src/Avalonia.Input/KeyboardNavigation.cs
  23. 6 41
      src/Avalonia.Input/KeyboardNavigationHandler.cs
  24. 0 242
      src/Avalonia.Input/Navigation/DirectionalNavigation.cs
  25. 1 1
      src/Avalonia.Input/Navigation/TabNavigation.cs
  26. 70 0
      src/Avalonia.Input/NavigationDirection.cs
  27. 1 6
      src/Avalonia.Themes.Default/MenuItem.xaml
  28. 2 1
      src/Avalonia.Themes.Default/Separator.xaml
  29. 72 0
      tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs
  30. 507 0
      tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs
  31. 1 1
      tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
  32. 0 799
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs
  33. 0 31
      tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs

+ 2 - 1
src/Avalonia.Controls/Canvas.cs

@@ -136,8 +136,9 @@ namespace Avalonia.Controls
         /// </summary>
         /// <param name="direction">The movement direction.</param>
         /// <param name="from">The control from which movement begins.</param>
+        /// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
         /// <returns>The control.</returns>
-        IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from)
+        IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
         {
             // TODO: Implement this
             return null;

+ 96 - 52
src/Avalonia.Controls/ContextMenu.cs

@@ -1,16 +1,18 @@
+using System;
+using System.Reactive.Linq;
+using System.Linq;
+using System.ComponentModel;
+using Avalonia.Controls.Platform;
+using System.Collections.Generic;
+using Avalonia.Input;
+using Avalonia.LogicalTree;
+using Avalonia.Controls.Primitives;
+
 namespace Avalonia.Controls
 {
-    using Input;
-    using Interactivity;
-    using LogicalTree;
-    using Primitives;
-    using System;
-    using System.Reactive.Linq;
-    using System.Linq;
-    using System.ComponentModel;
-
-    public class ContextMenu : SelectingItemsControl
+    public class ContextMenu : SelectingItemsControl, IMenu
     {
+        private readonly IMenuInteractionHandler _interaction;
         private bool _isOpen;
         private Popup _popup;
 
@@ -20,6 +22,25 @@ namespace Avalonia.Controls
         public static readonly DirectProperty<ContextMenu, bool> IsOpenProperty =
                             AvaloniaProperty.RegisterDirect<ContextMenu, bool>(nameof(IsOpen), o => o.IsOpen);
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ContextMenu"/> class.
+        /// </summary>
+        public ContextMenu()
+        {
+            _interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ??
+                new DefaultMenuInteractionHandler();
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ContextMenu"/> class.
+        /// </summary>
+        /// <param name="interactionHandler">The menu iteraction handler.</param>
+        public ContextMenu(IMenuInteractionHandler interactionHandler)
+        {
+            Contract.Requires<ArgumentNullException>(interactionHandler != null);
+
+            _interaction = interactionHandler;
+        }
 
         /// <summary>
         /// Initializes static members of the <see cref="ContextMenu"/> class.
@@ -27,8 +48,6 @@ namespace Avalonia.Controls
         static ContextMenu()
         {
             ContextMenuProperty.Changed.Subscribe(ContextMenuChanged);
-
-            MenuItem.ClickEvent.AddClassHandler<ContextMenu>(x => x.OnContextMenuClick, handledEventsToo: true);
         }
 
         /// <summary>
@@ -36,6 +55,36 @@ namespace Avalonia.Controls
         /// </summary>
         public bool IsOpen => _isOpen;
 
+        /// <inheritdoc/>
+        IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
+
+        /// <inheritdoc/>
+        IMenuItem IMenuElement.SelectedItem
+        {
+            get
+            {
+                var index = SelectedIndex;
+                return (index != -1) ?
+                    (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
+                    null;
+            }
+            set
+            {
+                SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
+            }
+        }
+
+        /// <inheritdoc/>
+        IEnumerable<IMenuItem> IMenuElement.SubItems
+        {
+            get
+            {
+                return ItemContainerGenerator.Containers
+                    .Select(x => x.ContainerControl)
+                    .OfType<IMenuItem>();
+            }
+        }
+
         /// <summary>
         /// Occurs when the value of the
         /// <see cref="P:Avalonia.Controls.ContextMenu.IsOpen" />
@@ -50,7 +99,6 @@ namespace Avalonia.Controls
         /// </summary>
         public event CancelEventHandler ContextMenuClosing;
 
-
         /// <summary>
         /// Called when the <see cref="Control.ContextMenu"/> property changes on a control.
         /// </summary>
@@ -71,62 +119,53 @@ namespace Avalonia.Controls
         }
 
         /// <summary>
-        /// Called when a submenu is clicked somewhere in the menu.
+        /// Opens the menu.
         /// </summary>
-        /// <param name="e">The event args.</param>
-        private void OnContextMenuClick(RoutedEventArgs e)
-        {
-            Hide();
-            FocusManager.Instance.Focus(null);
-            e.Handled = true;
-        }
+        public void Open() => Open(null);
 
         /// <summary>
-        /// Closes the menu.
+        /// Opens a context menu on the specified control.
         /// </summary>
-        public void Hide()
+        /// <param name="control">The control.</param>
+        public void Open(Control control)
         {
-            if (_popup != null && _popup.IsVisible)
+            if (_popup == null)
             {
-                _popup.IsOpen = false;
+                _popup = new Popup()
+                {
+                    PlacementMode = PlacementMode.Pointer,
+                    PlacementTarget = control,
+                    StaysOpen = false,
+                    ObeyScreenEdges = true
+                };
+
+                _popup.Closed += PopupClosed;
+                _interaction.Attach(this);
             }
 
-            SelectedIndex = -1;
+            ((ISetLogicalParent)_popup).SetParent(control);
+            _popup.Child = this;
+            _popup.IsOpen = true;
 
-            SetAndRaise(IsOpenProperty, ref _isOpen, false);
+            SetAndRaise(IsOpenProperty, ref _isOpen, true);
         }
 
         /// <summary>
-        /// Shows a context menu for the specified control.
+        /// Closes the menu.
         /// </summary>
-        /// <param name="control">The control.</param>
-        private void Show(Control control)
+        public void Close()
         {
-            if (control != null)
+            if (_popup != null && _popup.IsVisible)
             {
-                if (_popup == null)
-                {
-                    _popup = new Popup()
-                    {
-                        PlacementMode = PlacementMode.Pointer,
-                        PlacementTarget = control,
-                        StaysOpen = false,
-                        ObeyScreenEdges = true
-                    };
-
-                    _popup.Closed += PopupClosed;
-                }
-
-                ((ISetLogicalParent)_popup).SetParent(control);
-                _popup.Child = this;
+                _popup.IsOpen = false;
+            }
 
-                _popup.IsOpen = true;
+            SelectedIndex = -1;
 
-                SetAndRaise(IsOpenProperty, ref _isOpen, true);
-            }
+            SetAndRaise(IsOpenProperty, ref _isOpen, false);
         }
 
-        private static void PopupClosed(object sender, EventArgs e)
+        private void PopupClosed(object sender, EventArgs e)
         {
             var contextMenu = (sender as Popup)?.Child as ContextMenu;
 
@@ -152,7 +191,7 @@ namespace Avalonia.Controls
                 if (contextMenu.CancelClosing())
                     return;
 
-                control.ContextMenu.Hide();
+                control.ContextMenu.Close();
                 e.Handled = true;
             }
 
@@ -161,7 +200,7 @@ namespace Avalonia.Controls
                 if (contextMenu.CancelOpening())
                     return;
 
-                contextMenu.Show(control);
+                contextMenu.Open(control);
                 e.Handled = true;
             }
         }
@@ -179,5 +218,10 @@ namespace Avalonia.Controls
             ContextMenuOpening?.Invoke(this, eventArgs);
             return eventArgs.Cancel;
         }
+
+        bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap)
+        {
+            throw new NotImplementedException();
+        }
     }
 }

+ 21 - 0
src/Avalonia.Controls/IMenu.cs

@@ -0,0 +1,21 @@
+using System;
+using Avalonia.Controls.Platform;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Represents a <see cref="Menu"/> or <see cref="ContextMenu"/>.
+    /// </summary>
+    public interface IMenu : IMenuElement
+    {
+        /// <summary>
+        /// Gets the menu interaction handler.
+        /// </summary>
+        IMenuInteractionHandler InteractionHandler { get; }
+
+        /// <summary>
+        /// Gets a value indicating whether the menu is open.
+        /// </summary>
+        bool IsOpen { get; }
+    }
+}

+ 40 - 0
src/Avalonia.Controls/IMenuElement.cs

@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Input;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Represents an <see cref="IMenu"/> or <see cref="IMenuItem"/>.
+    /// </summary>
+    public interface IMenuElement : IControl
+    {
+        /// <summary>
+        /// Gets or sets the currently selected submenu item.
+        /// </summary>
+        IMenuItem SelectedItem { get; set; }
+
+        /// <summary>
+        /// Gets the submenu items.
+        /// </summary>
+        IEnumerable<IMenuItem> SubItems { get; }
+
+        /// <summary>
+        /// Opens the menu or menu item.
+        /// </summary>
+        void Open();
+
+        /// <summary>
+        /// Closes the menu or menu item.
+        /// </summary>
+        void Close();
+
+        /// <summary>
+        /// Moves the submenu selection in the specified direction.
+        /// </summary>
+        /// <param name="direction">The direction.</param>
+        /// <param name="wrap">Whether to wrap after the first or last item.</param>
+        /// <returns>True if the selection was moved; otherwise false.</returns>
+        bool MoveSelection(NavigationDirection direction, bool wrap);
+    }
+}

+ 41 - 0
src/Avalonia.Controls/IMenuItem.cs

@@ -0,0 +1,41 @@
+using System;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Represents a <see cref="MenuItem"/>.
+    /// </summary>
+    public interface IMenuItem : IMenuElement
+    {
+        /// <summary>
+        /// Gets or sets a value that indicates whether the item has a submenu.
+        /// </summary>
+        bool HasSubMenu { get; }
+
+        /// <summary>
+        /// Gets a value indicating whether the mouse is currently over the menu item's submenu.
+        /// </summary>
+        bool IsPointerOverSubMenu { get; }
+
+        /// <summary>
+        /// Gets or sets a value that indicates whether the submenu of the <see cref="MenuItem"/> is
+        /// open.
+        /// </summary>
+        bool IsSubMenuOpen { get; set; }
+
+        /// <summary>
+        /// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level main menu item.
+        /// </summary>
+        bool IsTopLevel { get; }
+
+        /// <summary>
+        /// Gets the parent <see cref="IMenuElement"/>.
+        /// </summary>
+        new IMenuElement Parent { get; }
+
+        /// <summary>
+        /// Raises a click event on the menu item.
+        /// </summary>
+        void RaiseClick();
+    }
+}

+ 83 - 4
src/Avalonia.Controls/ItemsControl.cs

@@ -15,6 +15,7 @@ using Avalonia.Controls.Utils;
 using Avalonia.Input;
 using Avalonia.LogicalTree;
 using Avalonia.Metadata;
+using Avalonia.VisualTree;
 
 namespace Avalonia.Controls
 {
@@ -323,6 +324,46 @@ namespace Avalonia.Controls
             LogicalChildren.RemoveAll(toRemove);
         }
 
+        /// <summary>
+        /// Handles directional navigation within the <see cref="ItemsControl"/>.
+        /// </summary>
+        /// <param name="e">The key events.</param>
+        protected override void OnKeyDown(KeyEventArgs e)
+        {
+            if (!e.Handled)
+            {
+                var focus = FocusManager.Instance;
+                var direction = e.Key.ToNavigationDirection();
+                var container = Presenter?.Panel as INavigableContainer;
+
+                if (container == null ||
+                    focus.Current == null ||
+                    direction == null ||
+                    direction.Value.IsTab())
+                {
+                    return;
+                }
+
+                var current = focus.Current
+                    .GetSelfAndVisualAncestors()
+                    .OfType<IInputElement>()
+                    .FirstOrDefault(x => x.VisualParent == container);
+
+                if (current != null)
+                {
+                    var next = GetNextControl(container, direction.Value, current, false);
+
+                    if (next != null)
+                    {
+                        focus.Focus(next, NavigationMethod.Directional);
+                        e.Handled = true;
+                    }
+                }
+            }
+
+            base.OnKeyDown(e);
+        }
+
         /// <summary>
         /// Caled when the <see cref="Items"/> property changes.
         /// </summary>
@@ -335,6 +376,7 @@ namespace Avalonia.Controls
             var oldValue = e.OldValue as IEnumerable;
             var newValue = e.NewValue as IEnumerable;
 
+            UpdateItemCount();
             RemoveControlItemsFromLogicalChildren(oldValue);
             AddControlItemsToLogicalChildren(newValue);
             SubscribeToItems(newValue);
@@ -358,10 +400,8 @@ namespace Avalonia.Controls
                     RemoveControlItemsFromLogicalChildren(e.OldItems);
                     break;
             }
-            
-            int? count = (Items as IList)?.Count;
-            if (count != null)
-                ItemCount = (int)count;
+
+            UpdateItemCount();
 
             var collection = sender as ICollection;
             PseudoClasses.Set(":empty", collection == null || collection.Count == 0);
@@ -445,5 +485,44 @@ namespace Avalonia.Controls
                 // TODO: Rebuild the item containers.
             }
         }
+
+        private void UpdateItemCount()
+        {
+            if (Items == null)
+            {
+                ItemCount = 0;
+            }
+            else if (Items is IList list)
+            {
+                ItemCount = list.Count;
+            }
+            else
+            {
+                ItemCount = Items.Count();
+            }
+        }
+
+        protected static IInputElement GetNextControl(
+            INavigableContainer container,
+            NavigationDirection direction,
+            IInputElement from,
+            bool wrap)
+        {
+            IInputElement result;
+
+            do
+            {
+                result = container.GetControl(direction, from, wrap);
+
+                if (result?.Focusable == true)
+                {
+                    return result;
+                }
+
+                from = result;
+            } while (from != null);
+
+            return null;
+        }
     }
 }

+ 112 - 138
src/Avalonia.Controls/Menu.cs

@@ -2,30 +2,23 @@
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
 using System;
+using System.Collections.Generic;
 using System.Linq;
-using System.Reactive.Disposables;
 using Avalonia.Controls.Generators;
+using Avalonia.Controls.Platform;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Templates;
 using Avalonia.Input;
-using Avalonia.Input.Raw;
 using Avalonia.Interactivity;
 using Avalonia.LogicalTree;
-using Avalonia.Rendering;
 
 namespace Avalonia.Controls
 {
     /// <summary>
     /// A top-level menu control.
     /// </summary>
-    public class Menu : SelectingItemsControl, IFocusScope, IMainMenu
+    public class Menu : SelectingItemsControl, IFocusScope, IMainMenu, IMenu
     {
-        /// <summary>
-        /// Defines the default items panel used by a <see cref="Menu"/>.
-        /// </summary>
-        private static readonly ITemplate<IPanel> DefaultPanel =
-            new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
-
         /// <summary>
         /// Defines the <see cref="IsOpen"/> property.
         /// </summary>
@@ -34,12 +27,42 @@ namespace Avalonia.Controls
                 nameof(IsOpen),
                 o => o.IsOpen);
 
+        /// <summary>
+        /// Defines the <see cref="MenuOpened"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> MenuOpenedEvent =
+            RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuOpened), RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Defines the <see cref="MenuClosed"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<RoutedEventArgs> MenuClosedEvent =
+            RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(MenuClosed), RoutingStrategies.Bubble);
+
+        private static readonly ITemplate<IPanel> DefaultPanel =
+            new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
+        private readonly IMenuInteractionHandler _interaction;
         private bool _isOpen;
 
         /// <summary>
-        /// Tracks event handlers added to the root of the visual tree.
+        /// Initializes a new instance of the <see cref="Menu"/> class.
+        /// </summary>
+        public Menu()
+        {
+            _interaction = AvaloniaLocator.Current.GetService<IMenuInteractionHandler>() ?? 
+                new DefaultMenuInteractionHandler();
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="Menu"/> class.
         /// </summary>
-        private IDisposable _subscription;
+        /// <param name="interactionHandler">The menu iteraction handler.</param>
+        public Menu(IMenuInteractionHandler interactionHandler)
+        {
+            Contract.Requires<ArgumentNullException>(interactionHandler != null);
+
+            _interaction = interactionHandler;
+        }
 
         /// <summary>
         /// Initializes static members of the <see cref="Menu"/> class.
@@ -47,7 +70,6 @@ namespace Avalonia.Controls
         static Menu()
         {
             ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
-            MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick, handledEventsToo: true);
             MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened);
         }
 
@@ -60,18 +82,52 @@ namespace Avalonia.Controls
             private set { SetAndRaise(IsOpenProperty, ref _isOpen, value); }
         }
 
-        /// <summary>
-        /// Gets the selected <see cref="MenuItem"/> container.
-        /// </summary>
-        private MenuItem SelectedMenuItem
+        /// <inheritdoc/>
+        IMenuInteractionHandler IMenu.InteractionHandler => _interaction;
+
+        /// <inheritdoc/>
+        IMenuItem IMenuElement.SelectedItem
         {
             get
             {
                 var index = SelectedIndex;
                 return (index != -1) ?
-                    (MenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
+                    (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
                     null;
             }
+            set
+            {
+                SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
+            }
+        }
+
+        /// <inheritdoc/>
+        IEnumerable<IMenuItem> IMenuElement.SubItems
+        {
+            get
+            {
+                return ItemContainerGenerator.Containers
+                    .Select(x => x.ContainerControl)
+                    .OfType<IMenuItem>();
+            }
+        }
+
+        /// <summary>
+        /// Occurs when a <see cref="Menu"/> is opened.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs> MenuOpened
+        {
+            add { AddHandler(MenuOpenedEvent, value); }
+            remove { RemoveHandler(MenuOpenedEvent, value); }
+        }
+
+        /// <summary>
+        /// Occurs when a <see cref="Menu"/> is closed.
+        /// </summary>
+        public event EventHandler<RoutedEventArgs> MenuClosed
+        {
+            add { AddHandler(MenuClosedEvent, value); }
+            remove { RemoveHandler(MenuClosedEvent, value); }
         }
 
         /// <summary>
@@ -79,13 +135,22 @@ namespace Avalonia.Controls
         /// </summary>
         public void Close()
         {
-            foreach (MenuItem i in this.GetLogicalChildren())
+            if (IsOpen)
             {
-                i.IsSubMenuOpen = false;
-            }
+                foreach (var i in ((IMenu)this).SubItems)
+                {
+                    i.Close();
+                }
+
+                IsOpen = false;
+                SelectedIndex = -1;
 
-            IsOpen = false;
-            SelectedIndex = -1;
+                RaiseEvent(new RoutedEventArgs
+                {
+                    RoutedEvent = MenuClosedEvent,
+                    Source = this,
+                });
+            }
         }
 
         /// <summary>
@@ -93,9 +158,25 @@ namespace Avalonia.Controls
         /// </summary>
         public void Open()
         {
-            SelectedIndex = 0;
-            SelectedMenuItem.Focus();
-            IsOpen = true;
+            if (!IsOpen)
+            {
+                IsOpen = true;
+
+                RaiseEvent(new RoutedEventArgs
+                {
+                    RoutedEvent = MenuOpenedEvent,
+                    Source = this,
+                });
+            }
+        }
+
+        /// <inheritdoc/>
+        bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
+
+        /// <inheritdoc/>
+        protected override IItemContainerGenerator CreateItemContainerGenerator()
+        {
+            return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
         }
 
         /// <inheritdoc/>
@@ -103,79 +184,27 @@ namespace Avalonia.Controls
         {
             base.OnAttachedToVisualTree(e);
 
-            var topLevel = (TopLevel)e.Root;
-            var window = e.Root as Window;
-
-            if (window != null)
-                window.Deactivated += Deactivated;
-
-            var pointerPress = topLevel.AddHandler(
-                PointerPressedEvent,
-                TopLevelPreviewPointerPress,
-                RoutingStrategies.Tunnel);
-
-            _subscription = new CompositeDisposable(
-                pointerPress,
-                Disposable.Create(() =>
-                {
-                    if (window != null)
-                        window.Deactivated -= Deactivated;
-                }),
-                InputManager.Instance.Process.Subscribe(ListenForNonClientClick));
-
             var inputRoot = e.Root as IInputRoot;
 
             if (inputRoot?.AccessKeyHandler != null)
             {
                 inputRoot.AccessKeyHandler.MainMenu = this;
             }
+
+            _interaction.Attach(this);
         }
 
         /// <inheritdoc/>
         protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
         {
             base.OnDetachedFromVisualTree(e);
-            _subscription.Dispose();
+            _interaction.Detach(this);
         }
 
         /// <inheritdoc/>
-        protected override IItemContainerGenerator CreateItemContainerGenerator()
-        {
-            return new ItemContainerGenerator<MenuItem>(this, MenuItem.HeaderProperty, null);
-        }
-
-        /// <summary>
-        /// Called when a key is pressed within the menu.
-        /// </summary>
-        /// <param name="e">The event args.</param>
         protected override void OnKeyDown(KeyEventArgs e)
         {
-            bool menuWasOpen = SelectedMenuItem?.IsSubMenuOpen ?? false;
-
-            base.OnKeyDown(e);
-
-            if (menuWasOpen)
-            {
-                // If a menu item was open and we navigate to a new one with the arrow keys, open
-                // that menu and select the first item.
-                var selection = SelectedMenuItem;
-
-                if (selection != null && !selection.IsSubMenuOpen)
-                {
-                    selection.IsSubMenuOpen = true;
-                    selection.SelectedIndex = 0;
-                }
-            }
-        }
-
-        /// <summary>
-        /// Called when the menu loses focus.
-        /// </summary>
-        /// <param name="e">The event args.</param>
-        protected override void OnLostFocus(RoutedEventArgs e)
-        {
-            base.OnLostFocus(e);
-            SelectedItem = null;
+            // Don't handle here: let the interaction handler handle it.
         }
 
         /// <summary>
@@ -184,9 +213,7 @@ namespace Avalonia.Controls
         /// <param name="e">The event args.</param>
         protected virtual void OnSubmenuOpened(RoutedEventArgs e)
         {
-            var menuItem = e.Source as MenuItem;
-
-            if (menuItem != null && menuItem.Parent == this)
+            if (e.Source is MenuItem menuItem && menuItem.Parent == this)
             {
                 foreach (var child in this.GetLogicalChildren().OfType<MenuItem>())
                 {
@@ -199,58 +226,5 @@ namespace Avalonia.Controls
 
             IsOpen = true;
         }
-
-        /// <summary>
-        /// Called when the top-level window is deactivated.
-        /// </summary>
-        /// <param name="sender">The sender.</param>
-        /// <param name="e">The event args.</param>
-        private void Deactivated(object sender, EventArgs e)
-        {
-            Close();
-        }
-
-        /// <summary>
-        /// Listens for non-client clicks and closes the menu when one is detected.
-        /// </summary>
-        /// <param name="e">The raw event.</param>
-        private void ListenForNonClientClick(RawInputEventArgs e)
-        {
-            var mouse = e as RawMouseEventArgs;
-
-            if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown)
-            {
-                Close();
-            }
-        }
-
-        /// <summary>
-        /// Called when a submenu is clicked somewhere in the menu.
-        /// </summary>
-        /// <param name="e">The event args.</param>
-        private void OnMenuClick(RoutedEventArgs e)
-        {
-            Close();
-            FocusManager.Instance.Focus(null);
-            e.Handled = true;
-        }
-
-        /// <summary>
-        /// Called when the pointer is pressed anywhere on the window.
-        /// </summary>
-        /// <param name="sender">The sender.</param>
-        /// <param name="e">The event args.</param>
-        private void TopLevelPreviewPointerPress(object sender, PointerPressedEventArgs e)
-        {
-            if (IsOpen)
-            {
-                var control = e.Source as ILogical;
-
-                if (!this.IsLogicalParentOf(control))
-                {
-                    Close();
-                }
-            }
-        }
     }
 }

+ 131 - 170
src/Avalonia.Controls/MenuItem.cs

@@ -2,6 +2,7 @@
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
 using System;
+using System.Collections.Generic;
 using System.Linq;
 using System.Windows.Input;
 using Avalonia.Controls.Generators;
@@ -11,14 +12,13 @@ using Avalonia.Controls.Templates;
 using Avalonia.Input;
 using Avalonia.Interactivity;
 using Avalonia.LogicalTree;
-using Avalonia.Threading;
 
 namespace Avalonia.Controls
 {
     /// <summary>
     /// A menu item control.
     /// </summary>
-    public class MenuItem : HeaderedSelectingItemsControl, ISelectable
+    public class MenuItem : HeaderedSelectingItemsControl, IMenuItem, ISelectable
     {
         /// <summary>
         /// Defines the <see cref="Command"/> property.
@@ -62,6 +62,18 @@ namespace Avalonia.Controls
         public static readonly RoutedEvent<RoutedEventArgs> ClickEvent =
             RoutedEvent.Register<MenuItem, RoutedEventArgs>(nameof(Click), RoutingStrategies.Bubble);
 
+        /// <summary>
+        /// Defines the <see cref="PointerEnterItem"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<PointerEventArgs> PointerEnterItemEvent =
+            RoutedEvent.Register<InputElement, PointerEventArgs>(nameof(PointerEnterItem), RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Defines the <see cref="PointerLeaveItem"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<PointerEventArgs> PointerLeaveItemEvent =
+            RoutedEvent.Register<InputElement, PointerEventArgs>(nameof(PointerLeaveItem), RoutingStrategies.Bubble);
+
         /// <summary>
         /// Defines the <see cref="SubmenuOpened"/> event.
         /// </summary>
@@ -72,15 +84,7 @@ namespace Avalonia.Controls
         /// The default value for the <see cref="ItemsControl.ItemsPanel"/> property.
         /// </summary>
         private static readonly ITemplate<IPanel> DefaultPanel =
-            new FuncTemplate<IPanel>(() => new StackPanel
-            {
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-            });
-
-        /// <summary>
-        /// The timer used to display submenus.
-        /// </summary>
-        private IDisposable _submenuTimer;
+            new FuncTemplate<IPanel>(() => new StackPanel());
 
         /// <summary>
         /// The submenu popup.
@@ -96,16 +100,15 @@ namespace Avalonia.Controls
             CommandProperty.Changed.Subscribe(CommandChanged);
             FocusableProperty.OverrideDefaultValue<MenuItem>(true);
             IconProperty.Changed.AddClassHandler<MenuItem>(x => x.IconChanged);
+            IsSelectedProperty.Changed.AddClassHandler<MenuItem>(x => x.IsSelectedChanged);
             ItemsPanelProperty.OverrideDefaultValue<MenuItem>(DefaultPanel);
             ClickEvent.AddClassHandler<MenuItem>(x => x.OnClick);
             SubmenuOpenedEvent.AddClassHandler<MenuItem>(x => x.OnSubmenuOpened);
             IsSubMenuOpenProperty.Changed.AddClassHandler<MenuItem>(x => x.SubMenuOpenChanged);
-            AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<MenuItem>(x => x.AccessKeyPressed);
         }
 
         public MenuItem()
         {
-
         }
 
         /// <summary>
@@ -117,6 +120,30 @@ namespace Avalonia.Controls
             remove { RemoveHandler(ClickEvent, value); }
         }
 
+        /// <summary>
+        /// Occurs when the pointer enters a menu item.
+        /// </summary>
+        /// <remarks>
+        /// A bubbling version of the <see cref="InputElement.PointerEnter"/> event for menu items.
+        /// </remarks>
+        public event EventHandler<PointerEventArgs> PointerEnterItem
+        {
+            add { AddHandler(PointerEnterItemEvent, value); }
+            remove { RemoveHandler(PointerEnterItemEvent, value); }
+        }
+
+        /// <summary>
+        /// Raised when the pointer leaves a menu item.
+        /// </summary>
+        /// <remarks>
+        /// A bubbling version of the <see cref="InputElement.PointerLeave"/> event for menu items.
+        /// </remarks>
+        public event EventHandler<PointerEventArgs> PointerLeaveItem
+        {
+            add { AddHandler(PointerLeaveItemEvent, value); }
+            remove { RemoveHandler(PointerLeaveItemEvent, value); }
+        }
+
         /// <summary>
         /// Occurs when a <see cref="MenuItem"/>'s submenu is opened.
         /// </summary>
@@ -188,10 +215,71 @@ namespace Avalonia.Controls
         public bool HasSubMenu => !Classes.Contains(":empty");
 
         /// <summary>
-        /// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level menu item.
+        /// Gets a value that indicates whether the <see cref="MenuItem"/> is a top-level main menu item.
         /// </summary>
         public bool IsTopLevel => Parent is Menu;
 
+        /// <inheritdoc/>
+        bool IMenuItem.IsPointerOverSubMenu => _popup.PopupRoot?.IsPointerOver ?? false;
+
+        /// <inheritdoc/>
+        IMenuElement IMenuItem.Parent => Parent as IMenuElement;
+
+        /// <inheritdoc/>
+        bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
+
+        /// <inheritdoc/>
+        IMenuItem IMenuElement.SelectedItem
+        {
+            get
+            {
+                var index = SelectedIndex;
+                return (index != -1) ?
+                    (IMenuItem)ItemContainerGenerator.ContainerFromIndex(index) :
+                    null;
+            }
+            set
+            {
+                SelectedIndex = ItemContainerGenerator.IndexFromContainer(value);
+            }
+        }
+
+        /// <inheritdoc/>
+        IEnumerable<IMenuItem> IMenuElement.SubItems
+        {
+            get
+            {
+                return ItemContainerGenerator.Containers
+                    .Select(x => x.ContainerControl)
+                    .OfType<IMenuItem>();
+            }
+        }            
+
+        /// <summary>
+        /// Opens the submenu.
+        /// </summary>
+        /// <remarks>
+        /// This has the same effect as setting <see cref="IsSubMenuOpen"/> to true.
+        /// </remarks>
+        public void Open() => IsSubMenuOpen = true;
+
+        /// <summary>
+        /// Closes the submenu.
+        /// </summary>
+        /// <remarks>
+        /// This has the same effect as setting <see cref="IsSubMenuOpen"/> to false.
+        /// </remarks>
+        public void Close() => IsSubMenuOpen = false;
+
+        /// <inheritdoc/>
+        void IMenuItem.RaiseClick() => RaiseEvent(new RoutedEventArgs(ClickEvent));
+
+        /// <inheritdoc/>
+        protected override IItemContainerGenerator CreateItemContainerGenerator()
+        {
+            return new MenuItemContainerGenerator(this);
+        }
+
         /// <summary>
         /// Called when the <see cref="MenuItem"/> is clicked.
         /// </summary>
@@ -205,163 +293,43 @@ namespace Avalonia.Controls
             }
         }
 
-        /// <summary>
-        /// Called when the <see cref="MenuItem"/> recieves focus.
-        /// </summary>
-        /// <param name="e">The event args.</param>
+        /// <inheritdoc/>
         protected override void OnGotFocus(GotFocusEventArgs e)
         {
             base.OnGotFocus(e);
-            IsSelected = true;
+            e.Handled = UpdateSelectionFromEventSource(e.Source, true);
         }
 
         /// <inheritdoc/>
-        protected override IItemContainerGenerator CreateItemContainerGenerator()
-        {
-            return new MenuItemContainerGenerator(this);
-        }
-
-        /// <summary>
-        /// Called when a key is pressed in the <see cref="MenuItem"/>.
-        /// </summary>
-        /// <param name="e">The event args.</param>
         protected override void OnKeyDown(KeyEventArgs e)
         {
-            // Some keypresses we want to pass straight to the parent MenuItem/Menu without giving
-            // this MenuItem the chance to handle them. This is usually e.g. when the submenu is
-            // closed so passing them to the base would try to move the selection in a hidden
-            // submenu.
-            var passStraightToParent = true;
-
-            switch (e.Key)
-            {
-                case Key.Left:
-                    if (!IsTopLevel && IsSubMenuOpen)
-                    {
-                        IsSubMenuOpen = false;
-                        e.Handled = true;
-                    }
-
-                    passStraightToParent = IsTopLevel || !IsSubMenuOpen;
-                    break;
-
-                case Key.Right:
-                    if (!IsTopLevel && HasSubMenu && !IsSubMenuOpen)
-                    {
-                        SelectedIndex = 0;
-                        IsSubMenuOpen = true;
-                        e.Handled = true;
-                    }
-
-                    passStraightToParent = IsTopLevel || !IsSubMenuOpen;
-                    break;
-
-                case Key.Enter:
-                    if (HasSubMenu)
-                    {
-                        goto case Key.Right;
-                    }
-                    else
-                    {
-                        RaiseEvent(new RoutedEventArgs(ClickEvent));
-                        e.Handled = true;
-                    }
-
-                    break;
-
-                case Key.Escape:
-                    if (IsSubMenuOpen)
-                    {
-                        IsSubMenuOpen = false;
-                        e.Handled = true;
-                    }
-
-                    break;
-            }
-
-            if (!passStraightToParent)
-            {
-                base.OnKeyDown(e);
-            }
+            // Don't handle here: let event bubble up to menu.
         }
 
-        /// <summary>
-        /// Called when the pointer enters the <see cref="MenuItem"/>.
-        /// </summary>
-        /// <param name="e">The event args.</param>
+        /// <inheritdoc/>
         protected override void OnPointerEnter(PointerEventArgs e)
         {
             base.OnPointerEnter(e);
 
-            var menu = Parent as Menu;
-
-            if (menu != null)
-            {
-                if (menu.IsOpen)
-                {
-                    IsSubMenuOpen = true;
-                }
-            }
-            else if (HasSubMenu && !IsSubMenuOpen)
+            RaiseEvent(new PointerEventArgs
             {
-                _submenuTimer = DispatcherTimer.Run(
-                    () => IsSubMenuOpen = true,
-                    TimeSpan.FromMilliseconds(400));
-            }
-            else
-            {
-                var parentItem = Parent as MenuItem;
-                if (parentItem != null)
-                {
-                    foreach (var sibling in parentItem.Items
-                        .OfType<MenuItem>()
-                        .Where(x => x != this && x.IsSubMenuOpen))
-                    {
-                        sibling.CloseSubmenus();
-                        sibling.IsSubMenuOpen = false;
-                        sibling.IsSelected = false;
-                    }
-                }
-            }
+                Device = e.Device,
+                RoutedEvent = PointerEnterItemEvent,
+                Source = this,
+            });
         }
 
-        /// <summary>
-        /// Called when the pointer leaves the <see cref="MenuItem"/>.
-        /// </summary>
-        /// <param name="e">The event args.</param>
+        /// <inheritdoc/>
         protected override void OnPointerLeave(PointerEventArgs e)
         {
             base.OnPointerLeave(e);
 
-            if (_submenuTimer != null)
+            RaiseEvent(new PointerEventArgs
             {
-                _submenuTimer.Dispose();
-                _submenuTimer = null;
-            }
-        }
-
-        /// <summary>
-        /// Called when the pointer is pressed over the <see cref="MenuItem"/>.
-        /// </summary>
-        /// <param name="e">The event args.</param>
-        protected override void OnPointerPressed(PointerPressedEventArgs e)
-        {
-            base.OnPointerPressed(e);
-
-            if (!HasSubMenu)
-            {
-                RaiseEvent(new RoutedEventArgs(ClickEvent));
-            }
-            else if (IsTopLevel)
-            {
-                IsSubMenuOpen = !IsSubMenuOpen;
-            }
-            else
-            {
-                IsSubMenuOpen = true;
-            }
-
-            e.Handled = true;
+                Device = e.Device,
+                RoutedEvent = PointerLeaveItemEvent,
+                Source = this,
+            });
         }
 
         /// <summary>
@@ -374,7 +342,7 @@ namespace Avalonia.Controls
 
             if (menuItem != null && menuItem.Parent == this)
             {
-                foreach (var child in Items.OfType<MenuItem>())
+                foreach (var child in ((IMenuItem)this).SubItems)
                 {
                     if (child != menuItem && child.IsSubMenuOpen)
                     {
@@ -395,31 +363,12 @@ namespace Avalonia.Controls
             _popup.Closed += PopupClosed;
         }
 
-        /// <summary>
-        /// Called when the menu item's access key is pressed.
-        /// </summary>
-        /// <param name="e">The event args.</param>
-        private void AccessKeyPressed(RoutedEventArgs e)
-        {
-            if (HasSubMenu)
-            {
-                SelectedIndex = 0;
-                IsSubMenuOpen = true;
-            }
-            else
-            {
-                RaiseEvent(new RoutedEventArgs(ClickEvent));
-            }
-
-            e.Handled = true;
-        }
-
         /// <summary>
         /// Closes all submenus of the menu item.
         /// </summary>
         private void CloseSubmenus()
         {
-            foreach (var child in Items.OfType<MenuItem>())
+            foreach (var child in ((IMenuItem)this).SubItems)
             {
                 child.IsSubMenuOpen = false;
             }
@@ -479,6 +428,18 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Called when the <see cref="IsSelected"/> property changes.
+        /// </summary>
+        /// <param name="e">The property change event.</param>
+        private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if ((bool)e.NewValue)
+            {
+                Focus();
+            }
+        }
+
         /// <summary>
         /// Called when the <see cref="IsSubMenuOpen"/> property changes.
         /// </summary>

+ 459 - 0
src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs

@@ -0,0 +1,459 @@
+using System;
+using Avalonia.Input;
+using Avalonia.Input.Raw;
+using Avalonia.Interactivity;
+using Avalonia.LogicalTree;
+using Avalonia.Rendering;
+using Avalonia.Threading;
+
+namespace Avalonia.Controls.Platform
+{
+    /// <summary>
+    /// Provides the default keyboard and pointer interaction for menus.
+    /// </summary>
+    public class DefaultMenuInteractionHandler : IMenuInteractionHandler
+    {
+        private IDisposable _inputManagerSubscription;
+        private IRenderRoot _root;
+
+        public DefaultMenuInteractionHandler()
+            : this(Input.InputManager.Instance, DefaultDelayRun)
+        {
+        }
+
+        public DefaultMenuInteractionHandler(
+            IInputManager inputManager,
+            Action<Action, TimeSpan> delayRun)
+        {
+            InputManager = inputManager;
+            DelayRun = delayRun;
+        }
+
+        public virtual void Attach(IMenu menu)
+        {
+            if (Menu != null)
+            {
+                throw new NotSupportedException("DefaultMenuInteractionHandler is already attached.");
+            }
+
+            Menu = menu;
+            Menu.GotFocus += GotFocus;
+            Menu.LostFocus += LostFocus;
+            Menu.KeyDown += KeyDown;
+            Menu.PointerPressed += PointerPressed;
+            Menu.PointerReleased += PointerReleased;
+            Menu.AddHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed);
+            Menu.AddHandler(Avalonia.Controls.Menu.MenuOpenedEvent, this.MenuOpened);
+            Menu.AddHandler(MenuItem.PointerEnterItemEvent, PointerEnter);
+            Menu.AddHandler(MenuItem.PointerLeaveItemEvent, PointerLeave);
+
+            _root = Menu.VisualRoot;
+
+            if (_root is InputElement inputRoot)
+            {
+                inputRoot.AddHandler(InputElement.PointerPressedEvent, RootPointerPressed, RoutingStrategies.Tunnel);
+            }
+
+            if (_root is WindowBase window)
+            {
+                window.Deactivated += WindowDeactivated;
+            }
+
+            _inputManagerSubscription = InputManager.Process.Subscribe(RawInput);
+        }
+
+        public virtual void Detach(IMenu menu)
+        {
+            if (Menu != menu)
+            {
+                throw new NotSupportedException("DefaultMenuInteractionHandler is not attached to the menu.");
+            }
+
+            Menu.GotFocus -= GotFocus;
+            Menu.LostFocus -= LostFocus;
+            Menu.KeyDown -= KeyDown;
+            Menu.PointerPressed -= PointerPressed;
+            Menu.PointerReleased -= PointerReleased;
+            Menu.RemoveHandler(AccessKeyHandler.AccessKeyPressedEvent, AccessKeyPressed);
+            Menu.RemoveHandler(Avalonia.Controls.Menu.MenuOpenedEvent, this.MenuOpened);
+            Menu.RemoveHandler(MenuItem.PointerEnterItemEvent, PointerEnter);
+            Menu.RemoveHandler(MenuItem.PointerLeaveItemEvent, PointerLeave);
+
+            if (_root is InputElement inputRoot)
+            {
+                inputRoot.RemoveHandler(InputElement.PointerPressedEvent, RootPointerPressed);
+            }
+
+            if (_root is WindowBase root)
+            {
+                root.Deactivated -= WindowDeactivated;
+            }
+
+            _inputManagerSubscription.Dispose();
+
+            Menu = null;
+            _root = null;
+        }
+
+        protected Action<Action, TimeSpan> DelayRun { get; }
+
+        protected IInputManager InputManager { get; }
+
+        protected IMenu Menu { get; private set; }
+
+        protected static TimeSpan MenuShowDelay { get; } = TimeSpan.FromMilliseconds(400);
+
+        protected internal virtual void GotFocus(object sender, GotFocusEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (item?.Parent != null)
+            {
+                item.SelectedItem = item;
+            }
+        }
+
+        protected internal virtual void LostFocus(object sender, RoutedEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (item != null)
+            {
+                item.SelectedItem = null;
+            }
+        }
+
+        protected internal virtual void KeyDown(object sender, KeyEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (item != null)
+            {
+                KeyDown(item, 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.HasSubMenu && !item.IsSubMenuOpen)
+                        {
+                            Open(item, true);
+                            e.Handled = true;
+                        }
+                    }
+                    else
+                    {
+                        goto default;
+                    }
+                    break;
+
+                case Key.Left:
+                    if (item.Parent is IMenuItem parent && !parent.IsTopLevel && parent.IsSubMenuOpen)
+                    {
+                        parent.Close();
+                        parent.Focus();
+                        e.Handled = true;
+                    }
+                    else
+                    {
+                        goto default;
+                    }
+                    break;
+
+                case Key.Right:
+                    if (!item.IsTopLevel && item.HasSubMenu)
+                    {
+                        Open(item, true);
+                        e.Handled = true;
+                    }
+                    else
+                    {
+                        goto default;
+                    }
+                    break;
+
+                case Key.Enter:
+                    if (!item.HasSubMenu)
+                    {
+                        Click(item);
+                    }
+                    else
+                    {
+                        Open(item, true);
+                    }
+
+                    e.Handled = true;
+                    break;
+
+                case Key.Escape:
+                    if (item.Parent != null)
+                    {
+                        item.Parent.Close();
+                        item.Parent.Focus();
+                        e.Handled = true;
+                    }
+                    break;
+
+                default:
+                    var direction = e.Key.ToNavigationDirection();
+
+                    if (direction.HasValue && 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;
+                    }
+
+                    break;
+            }
+
+            if (!e.Handled && item.Parent is IMenuItem parentItem)
+            {
+                KeyDown(parentItem, e);
+            }
+        }
+
+        protected internal virtual void AccessKeyPressed(object sender, RoutedEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (item == null)
+            {
+                return;
+            }
+
+            if (item.HasSubMenu)
+            {
+                Open(item, true);
+            }
+            else
+            {
+                Click(item);
+            }
+
+            e.Handled = true;
+        }
+
+        protected internal virtual void PointerEnter(object sender, PointerEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (item?.Parent == null)
+            {
+                return;
+            }
+
+            if (item.IsTopLevel)
+            {
+                if (item.Parent.SelectedItem?.IsSubMenuOpen == true)
+                {
+                    item.Parent.SelectedItem.Close();
+                    SelectItemAndAncestors(item);
+                    Open(item, false);
+                }
+                else
+                {
+                    SelectItemAndAncestors(item);
+                }
+            }
+            else
+            {
+                SelectItemAndAncestors(item);
+
+                if (item.HasSubMenu)
+                {
+                    OpenWithDelay(item);
+                }
+                else if (item.Parent != null)
+                {
+                    foreach (var sibling in item.Parent.SubItems)
+                    {
+                        if (sibling.IsSubMenuOpen)
+                        {
+                            CloseWithDelay(sibling);
+                        }
+                    }
+                }
+            }
+        }
+
+        protected internal virtual void PointerLeave(object sender, PointerEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (item?.Parent == null)
+            {
+                return;
+            }
+
+            if (item.Parent.SelectedItem == item)
+            {
+                if (item.IsTopLevel)
+                {
+                    if (!((IMenu)item.Parent).IsOpen)
+                    {
+                        item.Parent.SelectedItem = null;
+                    }
+                }
+                else if (!item.HasSubMenu)
+                {
+                    item.Parent.SelectedItem = null;
+                }
+            }
+        }
+
+        protected internal virtual void PointerPressed(object sender, PointerPressedEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (e.MouseButton == MouseButton.Left && item?.HasSubMenu == true)
+            {
+                Open(item, false);
+                e.Handled = true;
+            }
+        }
+
+        protected internal virtual void PointerReleased(object sender, PointerReleasedEventArgs e)
+        {
+            var item = GetMenuItem(e.Source as IControl);
+
+            if (e.MouseButton == MouseButton.Left && item.HasSubMenu == false)
+            {
+                Click(item);
+                e.Handled = true;
+            }
+        }
+
+        protected internal virtual void MenuOpened(object sender, RoutedEventArgs e)
+        {
+            if (e.Source == Menu)
+            {
+                Menu.MoveSelection(NavigationDirection.First, true);
+            }
+        }
+
+        protected internal virtual void RawInput(RawInputEventArgs e)
+        {
+            var mouse = e as RawMouseEventArgs;
+
+            if (mouse?.Type == RawMouseEventType.NonClientLeftButtonDown)
+            {
+                Menu.Close();
+            }
+        }
+
+        protected internal virtual void RootPointerPressed(object sender, PointerPressedEventArgs e)
+        {
+            if (Menu?.IsOpen == true)
+            {
+                var control = e.Source as ILogical;
+
+                if (!Menu.IsLogicalParentOf(control))
+                {
+                    Menu.Close();
+                }
+            }
+        }
+
+        protected internal virtual void WindowDeactivated(object sender, EventArgs e)
+        {
+            Menu.Close();
+        }
+
+        protected void Click(IMenuItem item)
+        {
+            item.RaiseClick();
+            CloseMenu(item);
+        }
+
+        protected void CloseMenu(IMenuItem item)
+        {
+            var current = (IMenuElement)item;
+
+            while (current != null && !(current is IMenu))
+            {
+                current = (current as IMenuItem)?.Parent;
+            }
+
+            current?.Close();
+        }
+
+        protected void CloseWithDelay(IMenuItem item)
+        {
+            void Execute()
+            {
+                if (item.Parent?.SelectedItem != item)
+                {
+                    item.Close();
+                }
+            }
+
+            DelayRun(Execute, MenuShowDelay);
+        }
+
+        protected void Open(IMenuItem item, bool selectFirst)
+        {
+            item.Open();
+
+            if (selectFirst)
+            {
+                item.MoveSelection(NavigationDirection.First, true);
+            }
+        }
+
+        protected void OpenWithDelay(IMenuItem item)
+        {
+            void Execute()
+            {
+                if (item.Parent?.SelectedItem == item)
+                {
+                    Open(item, false);
+                }
+            }
+
+            DelayRun(Execute, MenuShowDelay);
+        }
+
+        protected void SelectItemAndAncestors(IMenuItem item)
+        {
+            var current = item;
+
+            while (current?.Parent != null)
+            {
+                current.Parent.SelectedItem = current;
+                current = current.Parent as IMenuItem;
+            }
+        }
+
+        protected static IMenuItem GetMenuItem(IControl item)
+        {
+            while (true)
+            {
+                if (item == null)
+                    return null;
+                if (item is IMenuItem menuItem)
+                    return menuItem;
+                item = item.Parent;
+            }
+        }
+
+        private static void DefaultDelayRun(Action action, TimeSpan timeSpan)
+        {
+            DispatcherTimer.RunOnce(action, timeSpan);
+        }
+    }
+}

+ 22 - 0
src/Avalonia.Controls/Platform/IMenuInteractionHandler.cs

@@ -0,0 +1,22 @@
+using System;
+using Avalonia.Input;
+
+namespace Avalonia.Controls.Platform
+{
+    /// <summary>
+    /// Handles user interaction for menus.
+    /// </summary>
+    public interface IMenuInteractionHandler
+    {
+        /// <summary>
+        /// Attaches the interaction handler to a menu.
+        /// </summary>
+        /// <param name="menu">The menu.</param>
+        void Attach(IMenu menu);
+
+        /// <summary>
+        /// Detaches the interaction handler from the attached menu.
+        /// </summary>
+        void Detach(IMenu menu);
+    }
+}

+ 1 - 8
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@@ -143,13 +143,6 @@ namespace Avalonia.Controls.Presenters
             Virtualizer = ItemVirtualizer.Create(this);
             ((ILogicalScrollable)this).InvalidateScroll?.Invoke();
 
-            if (!Panel.IsSet(KeyboardNavigation.DirectionalNavigationProperty))
-            {
-                KeyboardNavigation.SetDirectionalNavigation(
-                    (InputElement)Panel,
-                    KeyboardNavigationMode.Contained);
-            }
-
             KeyboardNavigation.SetTabNavigation(
                 (InputElement)Panel,
                 KeyboardNavigation.GetTabNavigation(this));
@@ -175,4 +168,4 @@ namespace Avalonia.Controls.Presenters
             ((ILogicalScrollable)this).InvalidateScroll?.Invoke();
         }
     }
-}
+}

+ 36 - 0
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -457,6 +457,42 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
+        /// <summary>
+        /// Moves the selection in the specified direction relative to the current selection.
+        /// </summary>
+        /// <param name="direction">The direction to move.</param>
+        /// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
+        /// <returns>True if the selection was moved; otherwise false.</returns>
+        protected bool MoveSelection(NavigationDirection direction, bool wrap)
+        {
+            var from = SelectedIndex != -1 ? ItemContainerGenerator.ContainerFromIndex(SelectedIndex) : null;
+            return MoveSelection(from, direction, wrap);
+        }
+
+        /// <summary>
+        /// Moves the selection in the specified direction relative to the specified container.
+        /// </summary>
+        /// <param name="from">The container which serves as a starting point for the movement.</param>
+        /// <param name="direction">The direction to move.</param>
+        /// <param name="wrap">Whether to wrap when the selection reaches the first or last item.</param>
+        /// <returns>True if the selection was moved; otherwise false.</returns>
+        protected bool MoveSelection(IControl from, NavigationDirection direction, bool wrap)
+        {
+            if (Presenter?.Panel is INavigableContainer container &&
+                GetNextControl(container, direction, from, wrap) is IControl next)
+            {
+                var index = ItemContainerGenerator.IndexFromContainer(next);
+
+                if (index != -1)
+                {
+                    SelectedIndex = index;
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
         /// <summary>
         /// Updates the selection for an item based on user interaction.
         /// </summary>

+ 48 - 10
src/Avalonia.Controls/StackPanel.cs

@@ -56,11 +56,49 @@ namespace Avalonia.Controls
         /// </summary>
         /// <param name="direction">The movement direction.</param>
         /// <param name="from">The control from which movement begins.</param>
+        /// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
         /// <returns>The control.</returns>
-        IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from)
+        IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
         {
-            var fromControl = from as IControl;
-            return (fromControl != null) ? GetControlInDirection(direction, fromControl) : null;
+            var result = GetControlInDirection(direction, from as IControl);
+
+            if (result == null && wrap)
+            {
+                if (Orientation == Orientation.Vertical)
+                {
+                    switch (direction)
+                    {
+                        case NavigationDirection.Up:
+                        case NavigationDirection.Previous:
+                        case NavigationDirection.PageUp:
+                            result = GetControlInDirection(NavigationDirection.Last, null);
+                            break;
+                        case NavigationDirection.Down:
+                        case NavigationDirection.Next:
+                        case NavigationDirection.PageDown:
+                            result = GetControlInDirection(NavigationDirection.First, null);
+                            break;
+                    }
+                }
+                else
+                {
+                    switch (direction)
+                    {
+                        case NavigationDirection.Left:
+                        case NavigationDirection.Previous:
+                        case NavigationDirection.PageUp:
+                            result = GetControlInDirection(NavigationDirection.Last, null);
+                            break;
+                        case NavigationDirection.Right:
+                        case NavigationDirection.Next:
+                        case NavigationDirection.PageDown:
+                            result = GetControlInDirection(NavigationDirection.First, null);
+                            break;
+                    }
+                }
+            }
+
+            return result;
         }
 
         /// <summary>
@@ -72,7 +110,7 @@ namespace Avalonia.Controls
         protected virtual IInputElement GetControlInDirection(NavigationDirection direction, IControl from)
         {
             var horiz = Orientation == Orientation.Horizontal;
-            int index = Children.IndexOf((IControl)from);
+            int index = from != null ? Children.IndexOf(from) : -1;
 
             switch (direction)
             {
@@ -83,22 +121,22 @@ namespace Avalonia.Controls
                     index = Children.Count - 1;
                     break;
                 case NavigationDirection.Next:
-                    ++index;
+                    if (index != -1) ++index;
                     break;
                 case NavigationDirection.Previous:
-                    --index;
+                    if (index != -1) --index;
                     break;
                 case NavigationDirection.Left:
-                    index = horiz ? index - 1 : -1;
+                    if (index != -1) index = horiz ? index - 1 : -1;
                     break;
                 case NavigationDirection.Right:
-                    index = horiz ? index + 1 : -1;
+                    if (index != -1) index = horiz ? index + 1 : -1;
                     break;
                 case NavigationDirection.Up:
-                    index = horiz ? -1 : index - 1;
+                    if (index != -1) index = horiz ? -1 : index - 1;
                     break;
                 case NavigationDirection.Down:
-                    index = horiz ? -1 : index + 1;
+                    if (index != -1) index = horiz ? -1 : index + 1;
                     break;
                 default:
                     index = -1;

+ 86 - 0
src/Avalonia.Controls/TreeView.cs

@@ -136,6 +136,92 @@ namespace Avalonia.Controls
             }
         }
 
+        protected override void OnKeyDown(KeyEventArgs e)
+        {
+            var direction = e.Key.ToNavigationDirection();
+
+            if (direction?.IsDirectional() == true && !e.Handled)
+            {
+                if (SelectedItem != null)
+                {
+                    var next = GetContainerInDirection(
+                        GetContainerFromEventSource(e.Source) as TreeViewItem,
+                        direction.Value,
+                        true);
+
+                    if (next != null)
+                    {
+                        FocusManager.Instance.Focus(next, NavigationMethod.Directional);
+                        e.Handled = true;
+                    }
+                }
+                else
+                {
+                    SelectedItem = ElementAt(Items, 0);
+                }
+            }
+        }
+
+        private TreeViewItem GetContainerInDirection(
+            TreeViewItem from,
+            NavigationDirection direction,
+            bool intoChildren)
+        {
+            IItemContainerGenerator parentGenerator;
+
+            if (from?.Parent is TreeView treeView)
+            {
+                parentGenerator = treeView.ItemContainerGenerator;
+            }
+            else if (from?.Parent is TreeViewItem item)
+            {
+                parentGenerator = item.ItemContainerGenerator;
+            }
+            else
+            {
+                return null;
+            }
+
+            var index = parentGenerator.IndexFromContainer(from);
+            var parent = from.Parent as ItemsControl;
+            TreeViewItem result = null;
+
+            switch (direction)
+            {
+                case NavigationDirection.Up:
+                    if (index > 0)
+                    {
+                        var previous = (TreeViewItem)parentGenerator.ContainerFromIndex(index - 1);
+                        result = previous.IsExpanded ?
+                            (TreeViewItem)previous.ItemContainerGenerator.ContainerFromIndex(previous.ItemCount - 1) :
+                            previous;
+                    }
+                    else
+                    {
+                        result = from.Parent as TreeViewItem;
+                    }
+
+                    break;
+
+                case NavigationDirection.Down:
+                    if (from.IsExpanded && intoChildren)
+                    {
+                        result = (TreeViewItem)from.ItemContainerGenerator.ContainerFromIndex(0);
+                    }
+                    else if (index < parent?.ItemCount - 1)
+                    {
+                        result = (TreeViewItem)parentGenerator.ContainerFromIndex(index + 1);
+                    }
+                    else if (parent is TreeViewItem parentItem)
+                    {
+                        return GetContainerInDirection(parentItem, direction, false);
+                    }
+                    break;
+            }
+
+            return result;
+        }
+
         /// <inheritdoc/>
         protected override void OnPointerPressed(PointerPressedEventArgs e)
         {

+ 2 - 5
src/Avalonia.Controls/TreeViewItem.cs

@@ -32,10 +32,7 @@ namespace Avalonia.Controls
             ListBoxItem.IsSelectedProperty.AddOwner<TreeViewItem>();
 
         private static readonly ITemplate<IPanel> DefaultPanel =
-            new FuncTemplate<IPanel>(() => new StackPanel
-            {                
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-            });
+            new FuncTemplate<IPanel>(() => new StackPanel());
 
         private TreeView _treeView;
         private bool _isExpanded;
@@ -127,7 +124,7 @@ namespace Avalonia.Controls
                 }
             }
 
-            base.OnKeyDown(e);
+            // Don't call base.OnKeyDown - let events bubble up to containing TreeView.
         }
     }
 }

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

@@ -47,8 +47,9 @@ namespace Avalonia.Controls
         /// </summary>
         /// <param name="direction">The movement direction.</param>
         /// <param name="from">The control from which movement begins.</param>
+        /// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
         /// <returns>The control.</returns>
-        IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from)
+        IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from, bool wrap)
         {
             var horiz = Orientation == Orientation.Horizontal;
             int index = Children.IndexOf((IControl)from);
@@ -250,4 +251,4 @@ namespace Avalonia.Controls
             }
         }
     }
-}
+}

+ 29 - 8
src/Avalonia.Input/AccessKeyHandler.cs

@@ -53,10 +53,32 @@ namespace Avalonia.Input
         /// </summary>
         private IInputElement _restoreFocusElement;
 
+        /// <summary>
+        /// The window's main menu.
+        /// </summary>
+        private IMainMenu _mainMenu;
+
         /// <summary>
         /// Gets or sets the window's main menu.
         /// </summary>
-        public IMainMenu MainMenu { get; set; }
+        public IMainMenu MainMenu
+        {
+            get => _mainMenu;
+            set
+            {
+                if (_mainMenu != null)
+                {
+                    _mainMenu.MenuClosed -= MainMenuClosed;
+                }
+
+                _mainMenu = value;
+
+                if (_mainMenu != null)
+                {
+                    _mainMenu.MenuClosed += MainMenuClosed;
+                }
+            }
+        }
 
         /// <summary>
         /// Sets the owner of the access key handler.
@@ -160,13 +182,7 @@ namespace Avalonia.Input
         {
             bool menuIsOpen = MainMenu?.IsOpen == true;
 
-            if (e.Key == Key.Escape && menuIsOpen)
-            {
-                // When the Escape key is pressed with the main menu open, close it.
-                CloseMenu();
-                e.Handled = true;
-            }
-            else if ((e.Modifiers & InputModifiers.Alt) != 0 || menuIsOpen)
+            if ((e.Modifiers & InputModifiers.Alt) != 0 || menuIsOpen)
             {
                 // If any other key is pressed with the Alt key held down, or the main menu is open,
                 // find all controls who have registered that access key.
@@ -245,5 +261,10 @@ namespace Avalonia.Input
             MainMenu.Close();
             _owner.ShowAccessKeys = _showingAccessKeys = false;
         }
+
+        private void MainMenuClosed(object sender, EventArgs e)
+        {
+            _owner.ShowAccessKeys = false;
+        }
     }
 }

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

@@ -15,7 +15,7 @@ namespace Avalonia.Input
         /// <summary>
         /// Occurs when the control receives focus.
         /// </summary>
-        event EventHandler<RoutedEventArgs> GotFocus;
+        event EventHandler<GotFocusEventArgs> GotFocus;
 
         /// <summary>
         /// Occurs when the control loses focus.

+ 7 - 0
src/Avalonia.Input/IMainMenu.cs

@@ -1,6 +1,8 @@
 // Copyright (c) The Avalonia Project. All rights reserved.
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
+using System;
+using Avalonia.Interactivity;
 using Avalonia.VisualTree;
 
 namespace Avalonia.Input
@@ -24,5 +26,10 @@ namespace Avalonia.Input
         /// Opens the menu in response to the Alt/F10 key.
         /// </summary>
         void Open();
+
+        /// <summary>
+        /// Occurs when the main menu closes.
+        /// </summary>
+        event EventHandler<RoutedEventArgs> MenuClosed;
     }
 }

+ 2 - 1
src/Avalonia.Input/INavigableContainer.cs

@@ -13,7 +13,8 @@ namespace Avalonia.Input
         /// </summary>
         /// <param name="direction">The movement direction.</param>
         /// <param name="from">The control from which movement begins.</param>
+        /// <param name="wrap">Whether to wrap around when the first or last item is reached.</param>
         /// <returns>The control.</returns>
-        IInputElement GetControl(NavigationDirection direction, IInputElement from);
+        IInputElement GetControl(NavigationDirection direction, IInputElement from, bool wrap);
     }
 }

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

@@ -177,7 +177,7 @@ namespace Avalonia.Input
         /// <summary>
         /// Occurs when the control receives focus.
         /// </summary>
-        public event EventHandler<RoutedEventArgs> GotFocus
+        public event EventHandler<GotFocusEventArgs> GotFocus
         {
             add { AddHandler(GotFocusEvent, value); }
             remove { RemoveHandler(GotFocusEvent, value); }

+ 0 - 33
src/Avalonia.Input/KeyboardNavigation.cs

@@ -8,19 +8,6 @@ namespace Avalonia.Input
     /// </summary>
     public static class KeyboardNavigation
     {
-        /// <summary>
-        /// Defines the DirectionalNavigation attached property.
-        /// </summary>
-        /// <remarks>
-        /// The DirectionalNavigation attached property defines how pressing arrow keys causes
-        /// focus to be navigated between the children of the container.
-        /// </remarks>
-        public static readonly AttachedProperty<KeyboardNavigationMode> DirectionalNavigationProperty =
-            AvaloniaProperty.RegisterAttached<InputElement, KeyboardNavigationMode>(
-                "DirectionalNavigation",
-                typeof(KeyboardNavigation),
-                KeyboardNavigationMode.None);
-
         /// <summary>
         /// Defines the TabNavigation attached property.
         /// </summary>
@@ -46,26 +33,6 @@ namespace Avalonia.Input
                 "TabOnceActiveElement",
                 typeof(KeyboardNavigation));
 
-        /// <summary>
-        /// Gets the <see cref="DirectionalNavigationProperty"/> for a container.
-        /// </summary>
-        /// <param name="element">The container.</param>
-        /// <returns>The <see cref="KeyboardNavigationMode"/> for the container.</returns>
-        public static KeyboardNavigationMode GetDirectionalNavigation(InputElement element)
-        {
-            return element.GetValue(DirectionalNavigationProperty);
-        }
-
-        /// <summary>
-        /// Sets the <see cref="DirectionalNavigationProperty"/> for a container.
-        /// </summary>
-        /// <param name="element">The container.</param>
-        /// <param name="value">The <see cref="KeyboardNavigationMode"/> for the container.</param>
-        public static void SetDirectionalNavigation(InputElement element, KeyboardNavigationMode value)
-        {
-            element.SetValue(DirectionalNavigationProperty, value);
-        }
-
         /// <summary>
         /// Gets the <see cref="TabNavigationProperty"/> for a container.
         /// </summary>

+ 6 - 41
src/Avalonia.Input/KeyboardNavigationHandler.cs

@@ -85,7 +85,7 @@ namespace Avalonia.Input
             }
             else
             {
-                return DirectionalNavigation.GetNext(element, direction);
+                throw new NotSupportedException();
             }
         }
 
@@ -122,47 +122,12 @@ namespace Avalonia.Input
         {
             var current = FocusManager.Instance.Current;
 
-            if (current != null)
+            if (current != null && e.Key == Key.Tab)
             {
-                NavigationDirection? direction = null;
-
-                switch (e.Key)
-                {
-                    case Key.Tab:
-                        direction = (e.Modifiers & InputModifiers.Shift) == 0 ?
-                            NavigationDirection.Next : NavigationDirection.Previous;
-                        break;
-                    case Key.Up:
-                        direction = NavigationDirection.Up;
-                        break;
-                    case Key.Down:
-                        direction = NavigationDirection.Down;
-                        break;
-                    case Key.Left:
-                        direction = NavigationDirection.Left;
-                        break;
-                    case Key.Right:
-                        direction = NavigationDirection.Right;
-                        break;
-                    case Key.PageUp:
-                        direction = NavigationDirection.PageUp;
-                        break;
-                    case Key.PageDown:
-                        direction = NavigationDirection.PageDown;
-                        break;
-                    case Key.Home:
-                        direction = NavigationDirection.First;
-                        break;
-                    case Key.End:
-                        direction = NavigationDirection.Last;
-                        break;
-                }
-
-                if (direction.HasValue)
-                {
-                    Move(current, direction.Value, e.Modifiers);
-                    e.Handled = true;
-                }
+                var direction = (e.Modifiers & InputModifiers.Shift) == 0 ?
+                    NavigationDirection.Next : NavigationDirection.Previous;
+                Move(current, direction, e.Modifiers);
+                e.Handled = true;
             }
         }
     }

+ 0 - 242
src/Avalonia.Input/Navigation/DirectionalNavigation.cs

@@ -1,242 +0,0 @@
-// Copyright (c) The Avalonia Project. All rights reserved.
-// Licensed under the MIT license. See licence.md file in the project root for full license information.
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Avalonia.VisualTree;
-
-namespace Avalonia.Input.Navigation
-{
-    /// <summary>
-    /// The implementation for default directional navigation.
-    /// </summary>
-    public static class DirectionalNavigation
-    {
-        /// <summary>
-        /// Gets the next control in the specified navigation direction.
-        /// </summary>
-        /// <param name="element">The element.</param>
-        /// <param name="direction">The navigation direction.</param>
-        /// <returns>
-        /// The next element in the specified direction, or null if <paramref name="element"/>
-        /// was the last in the requested direction.
-        /// </returns>
-        public static IInputElement GetNext(
-            IInputElement element,
-            NavigationDirection direction)
-        {
-            Contract.Requires<ArgumentNullException>(element != null);
-            Contract.Requires<ArgumentException>(
-                direction != NavigationDirection.Next &&
-                direction != NavigationDirection.Previous);
-
-            var container = element.GetVisualParent<IInputElement>();
-
-            if (container != null)
-            {
-                var mode = KeyboardNavigation.GetDirectionalNavigation((InputElement)container);
-
-                switch (mode)
-                {
-                    case KeyboardNavigationMode.Continue:
-                        return GetNextInContainer(element, container, direction) ??
-                               GetFirstInNextContainer(element, element, direction);
-                    case KeyboardNavigationMode.Cycle:
-                        return GetNextInContainer(element, container, direction) ??
-                               GetFocusableDescendant(container, direction);
-                    case KeyboardNavigationMode.Contained:
-                        return GetNextInContainer(element, container, direction);
-                    default:
-                        return null;
-                }
-            }
-            else
-            {
-                return GetFocusableDescendants(element).FirstOrDefault();
-            }
-        }
-
-        /// <summary>
-        /// Returns a value indicting whether the specified direction is forward.
-        /// </summary>
-        /// <param name="direction">The direction.</param>
-        /// <returns>True if the direction is forward.</returns>
-        private static bool IsForward(NavigationDirection direction)
-        {
-            return direction == NavigationDirection.Next ||
-                   direction == NavigationDirection.Last ||
-                   direction == NavigationDirection.Right ||
-                   direction == NavigationDirection.Down;
-        }
-
-        /// <summary>
-        /// Gets the first or last focusable descendant of the specified element.
-        /// </summary>
-        /// <param name="container">The element.</param>
-        /// <param name="direction">The direction to search.</param>
-        /// <returns>The element or null if not found.##</returns>
-        private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction)
-        {
-            return IsForward(direction) ?
-                GetFocusableDescendants(container).FirstOrDefault() :
-                GetFocusableDescendants(container).LastOrDefault();
-        }
-
-        /// <summary>
-        /// Gets the focusable descendants of the specified element.
-        /// </summary>
-        /// <param name="element">The element.</param>
-        /// <returns>The element's focusable descendants.</returns>
-        private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element)
-        {
-            var children = element.GetVisualChildren().OfType<IInputElement>();
-
-            foreach (var child in children)
-            {
-                if (child.CanFocus())
-                {
-                    yield return child;
-                }
-
-                if (child.CanFocusDescendants())
-                {
-                    foreach (var descendant in GetFocusableDescendants(child))
-                    {
-                        yield return descendant;
-                    }
-                }
-            }
-        }
-
-        /// <summary>
-        /// Gets the next item that should be focused in the specified container.
-        /// </summary>
-        /// <param name="element">The starting element/</param>
-        /// <param name="container">The container.</param>
-        /// <param name="direction">The direction.</param>
-        /// <returns>The next element, or null if the element is the last.</returns>
-        private static IInputElement GetNextInContainer(
-            IInputElement element,
-            IInputElement container,
-            NavigationDirection direction)
-        {
-            if (direction == NavigationDirection.Down)
-            {
-                var descendant = GetFocusableDescendants(element).FirstOrDefault();
-
-                if (descendant != null)
-                {
-                    return descendant;
-                }
-            }
-
-            if (container != null)
-            {
-                var navigable = container as INavigableContainer;
-
-                if (navigable != null)
-                {
-                    while (element != null)
-                    {
-                        element = navigable.GetControl(direction, element);
-
-                        if (element != null && element.CanFocus())
-                        {
-                            break;
-                        }
-                    }
-                }
-                else
-                {
-                    // TODO: Do a spatial search here if the container doesn't implement
-                    // INavigableContainer.
-                    element = null;
-                }
-
-                if (element != null && direction == NavigationDirection.Up)
-                {
-                    var descendant = GetFocusableDescendants(element).LastOrDefault();
-
-                    if (descendant != null)
-                    {
-                        return descendant;
-                    }
-                }
-
-                return element;
-            }
-
-            return null;
-        }
-
-        /// <summary>
-        /// Gets the first item that should be focused in the next container.
-        /// </summary>
-        /// <param name="element">The element being navigated away from.</param>
-        /// <param name="container">The container.</param>
-        /// <param name="direction">The direction of the search.</param>
-        /// <returns>The first element, or null if there are no more elements.</returns>
-        private static IInputElement GetFirstInNextContainer(
-            IInputElement element,
-            IInputElement container,
-            NavigationDirection direction)
-        {
-            var parent = container.GetVisualParent<IInputElement>();
-            var isForward = IsForward(direction);
-            IInputElement next = null;
-
-            if (parent != null)
-            {
-                if (!isForward && parent.CanFocus())
-                {
-                    return parent;
-                }
-
-                var siblings = parent.GetVisualChildren()
-                    .OfType<IInputElement>()
-                    .Where(FocusExtensions.CanFocusDescendants);
-                var sibling = isForward ? 
-                    siblings.SkipWhile(x => x != container).Skip(1).FirstOrDefault() : 
-                    siblings.TakeWhile(x => x != container).LastOrDefault();
-
-                if (sibling != null)
-                {
-                    if (sibling is ICustomKeyboardNavigation custom)
-                    {
-                        var (handled, customNext) = custom.GetNext(element, direction);
-
-                        if (handled)
-                        {
-                            return customNext;
-                        }
-                    }
-
-                    if (sibling.CanFocus())
-                    {
-                        next = sibling;
-                    }
-                    else
-                    {
-                        next = isForward ?
-                            GetFocusableDescendants(sibling).FirstOrDefault() :
-                            GetFocusableDescendants(sibling).LastOrDefault();
-                    }
-                }
-
-                if (next == null)
-                {
-                    next = GetFirstInNextContainer(element, parent, direction);
-                }
-            }
-            else
-            {
-                next = isForward ?
-                    GetFocusableDescendants(container).FirstOrDefault() :
-                    GetFocusableDescendants(container).LastOrDefault();
-            }
-
-            return next;
-        }
-    }
-}

+ 1 - 1
src/Avalonia.Input/Navigation/TabNavigation.cs

@@ -168,7 +168,7 @@ namespace Avalonia.Input.Navigation
                 {
                     while (element != null)
                     {
-                        element = navigable.GetControl(direction, element);
+                        element = navigable.GetControl(direction, element, false);
 
                         if (element != null && element.CanFocus())
                         {

+ 70 - 0
src/Avalonia.Input/NavigationDirection.cs

@@ -58,4 +58,74 @@ namespace Avalonia.Input
         /// </summary>
         PageDown,
     }
+
+    public static class NavigationDirectionExtensions
+    {
+        /// <summary>
+        /// Checks whether a <see cref="NavigationDirection"/> represents a tab movement.
+        /// </summary>
+        /// <param name="direction">The direction.</param>
+        /// <returns>
+        /// True if the direction represents a tab movement (<see cref="NavigationDirection.Next"/>
+        /// or <see cref="NavigationDirection.Previous"/>); otherwise false.
+        /// </returns>
+        public static bool IsTab(this NavigationDirection direction)
+        {
+            return direction == NavigationDirection.Next ||
+                direction == NavigationDirection.Previous;
+        }
+
+        /// <summary>
+        /// Checks whether a <see cref="NavigationDirection"/> represents a directional movement.
+        /// </summary>
+        /// <param name="direction">The direction.</param>
+        /// <returns>
+        /// True if the direction represents a directional movement (any value except 
+        /// <see cref="NavigationDirection.Next"/> and <see cref="NavigationDirection.Previous"/>);
+        /// otherwise false.
+        /// </returns>
+        public static bool IsDirectional(this NavigationDirection direction)
+        {
+            return direction > NavigationDirection.Previous ||
+                direction <= NavigationDirection.PageDown;
+        }
+
+        /// <summary>
+        /// Converts a keypress into a <see cref="NavigationDirection"/>.
+        /// </summary>
+        /// <param name="key">The key.</param>
+        /// <param name="modifiers">The keyboard modifiers.</param>
+        /// <returns>
+        /// A <see cref="NavigationDirection"/> if the keypress represents a navigation keypress.
+        /// </returns>
+        public static NavigationDirection? ToNavigationDirection(
+            this Key key,
+            InputModifiers modifiers = InputModifiers.None)
+        {
+            switch (key)
+            {
+                case Key.Tab:
+                    return (modifiers & InputModifiers.Shift) != 0 ?
+                        NavigationDirection.Next : NavigationDirection.Previous;
+                case Key.Up:
+                    return NavigationDirection.Up;
+                case Key.Down:
+                    return NavigationDirection.Down;
+                case Key.Left:
+                    return NavigationDirection.Left;
+                case Key.Right:
+                    return NavigationDirection.Right;
+                case Key.Home:
+                    return NavigationDirection.First;
+                case Key.End:
+                    return NavigationDirection.Last;
+                case Key.PageUp:
+                    return NavigationDirection.PageUp;
+                case Key.PageDown:
+                    return NavigationDirection.PageDown;
+                default:
+                    return null;
+            }
+        }
+    }
 }

+ 1 - 6
src/Avalonia.Themes.Default/MenuItem.xaml

@@ -127,11 +127,6 @@
     <Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/>
   </Style>
 
-  <Style Selector="MenuItem:pointerover /template/ Border#root">
-    <Setter Property="Background" Value="{DynamicResource ThemeAccentBrush4}"/>
-    <Setter Property="BorderBrush" Value="{DynamicResource ThemeAccentBrush}"/>
-  </Style>
-
   <Style Selector="MenuItem:empty /template/ Path#rightArrow">
     <Setter Property="IsVisible" Value="False"/>
   </Style>
@@ -139,4 +134,4 @@
   <Style Selector="MenuItem:disabled">
     <Setter Property="Opacity" Value="{DynamicResource ThemeDisabledOpacity}"/>
   </Style>
-</Styles>
+</Styles>

+ 2 - 1
src/Avalonia.Themes.Default/Separator.xaml

@@ -1,6 +1,7 @@
 <Styles xmlns="https://github.com/avaloniaui">
 
   <Style Selector="Separator">
+    <Setter Property="Focusable" Value="False"/>
     <Setter Property="Template">
       <ControlTemplate>
         <Border BorderBrush="{TemplateBinding BorderBrush}"
@@ -22,4 +23,4 @@
     <Setter Property="Height" Value="1"/>
   </Style>
 
-</Styles>
+</Styles>

+ 72 - 0
tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs

@@ -11,6 +11,7 @@ using Avalonia.VisualTree;
 using Xunit;
 using System.Collections.ObjectModel;
 using Avalonia.UnitTests;
+using Avalonia.Input;
 
 namespace Avalonia.Controls.UnitTests
 {
@@ -494,6 +495,77 @@ namespace Avalonia.Controls.UnitTests
             Assert.NotNull(NameScope.GetNameScope((TextBlock)container.Child));
         }
 
+        [Fact]
+        public void Focuses_Next_Item_On_Key_Down()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var items = new object[]
+                {
+                    new Button(),
+                    new Button(),
+                };
+
+                var target = new ItemsControl
+                {
+                    Template = GetTemplate(),
+                    Items = items,
+                };
+
+                var root = new TestRoot { Child = target };
+
+                target.ApplyTemplate();
+                target.Presenter.ApplyTemplate();
+                target.Presenter.Panel.Children[0].Focus();
+
+                target.RaiseEvent(new KeyEventArgs
+                {
+                    RoutedEvent = InputElement.KeyDownEvent,
+                    Key = Key.Down,
+                });
+
+                Assert.Equal(
+                    target.Presenter.Panel.Children[1],
+                    FocusManager.Instance.Current);
+            }
+        }
+
+        [Fact]
+        public void Does_Not_Focus_Non_Focusable_Item_On_Key_Down()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var items = new object[]
+                {
+                    new Button(),
+                    new Button { Focusable = false },
+                    new Button(),
+                };
+
+                var target = new ItemsControl
+                {
+                    Template = GetTemplate(),
+                    Items = items,
+                };
+
+                var root = new TestRoot { Child = target };
+
+                target.ApplyTemplate();
+                target.Presenter.ApplyTemplate();
+                target.Presenter.Panel.Children[0].Focus();
+
+                target.RaiseEvent(new KeyEventArgs
+                {
+                    RoutedEvent = InputElement.KeyDownEvent,
+                    Key = Key.Down,
+                });
+
+                Assert.Equal(
+                    target.Presenter.Panel.Children[2],
+                    FocusManager.Instance.Current);
+            }
+        }
+
         private class Item
         {
             public Item(string value)

+ 507 - 0
tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs

@@ -0,0 +1,507 @@
+using System;
+using Avalonia.Controls.Platform;
+using Avalonia.Input;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests.Platform
+{
+    public class DefaultMenuInteractionHandlerTests
+    {
+        public class TopLevel
+        {
+            [Fact]
+            public void Up_Opens_MenuItem_With_SubMenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
+                var e = new KeyEventArgs { Key = Key.Up, Source = item };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Down_Opens_MenuItem_With_SubMenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                var item = Mock.Of<IMenuItem>(x => x.IsTopLevel == true && x.HasSubMenu == true);
+                var e = new KeyEventArgs { Key = Key.Down, Source = item };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Right_Selects_Next_MenuItem()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(menu).Verify(x => x.MoveSelection(NavigationDirection.Right, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Left_Selects_Previous_MenuItem()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(menu).Verify(x => x.MoveSelection(NavigationDirection.Left, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Enter_On_Item_With_No_SubMenu_Causes_Click()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.RaiseClick());
+                Mock.Get(menu).Verify(x => x.Close());
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Escape_Closes_Parent_Menu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(menu).Verify(x => x.Close());
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void PointerEnter_Opens_Item_When_Old_Item_Is_Open()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                var menu = new Mock<IMenu>();
+                var item = Mock.Of<IMenuItem>(x =>
+                    x.IsSubMenuOpen == true &&
+                    x.IsTopLevel == true &&
+                    x.HasSubMenu == true &&
+                    x.Parent == menu.Object);
+                var nextItem = Mock.Of<IMenuItem>(x =>
+                    x.IsTopLevel == true &&
+                    x.HasSubMenu == true &&
+                    x.Parent == menu.Object);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = nextItem };
+
+                menu.SetupGet(x => x.SelectedItem).Returns(item);
+
+                target.PointerEnter(nextItem, e);
+
+                Mock.Get(item).Verify(x => x.Close());
+                menu.VerifySet(x => x.SelectedItem = nextItem);
+                Mock.Get(nextItem).Verify(x => x.Open());
+                Mock.Get(nextItem).Verify(x => x.MoveSelection(NavigationDirection.First, true), Times.Never);
+                Assert.False(e.Handled);
+
+            }
+
+            [Fact]
+            public void PointerLeave_Deselects_Item_When_Menu_Not_Open()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                menu.SetupGet(x => x.SelectedItem).Returns(item);
+                target.PointerLeave(item, e);
+
+                menu.VerifySet(x => x.SelectedItem = null);
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerLeave_Doesnt_Deselect_Item_When_Menu_Open()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                menu.SetupGet(x => x.IsOpen).Returns(true);
+                menu.SetupGet(x => x.SelectedItem).Returns(item);
+                target.PointerLeave(item, e);
+
+                menu.VerifySet(x => x.SelectedItem = null, Times.Never);
+                Assert.False(e.Handled);
+            }
+        }
+
+        public class NonTopLevel
+        {
+            [Fact]
+            public void Up_Selects_Previous_MenuItem()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(parentItem).Verify(x => x.MoveSelection(NavigationDirection.Up, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Down_Selects_Next_MenuItem()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(parentItem).Verify(x => x.MoveSelection(NavigationDirection.Down, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Left_Closes_Parent_SubMenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+                
+                Mock.Get(parentItem).Verify(x => x.Close());
+                Mock.Get(parentItem).Verify(x => x.Focus());
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Right_With_SubMenu_Items_Opens_SubMenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Right_On_TopLevel_Child_Navigates_TopLevel_Selection()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                var menu = new Mock<IMenu>();
+                var parentItem = Mock.Of<IMenuItem>(x => 
+                    x.IsSubMenuOpen == true &&
+                    x.IsTopLevel == true && 
+                    x.HasSubMenu == true && 
+                    x.Parent == menu.Object);
+                var nextItem = Mock.Of<IMenuItem>(x =>
+                    x.IsTopLevel == true &&
+                    x.HasSubMenu == true &&
+                    x.Parent == menu.Object);
+                var item = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
+                var e = new KeyEventArgs { Key = Key.Right, Source = item };
+
+                menu.Setup(x => x.MoveSelection(NavigationDirection.Right, true))
+                    .Callback(() => menu.SetupGet(x => x.SelectedItem).Returns(nextItem))
+                    .Returns(true);
+
+                target.KeyDown(item, e);
+
+                menu.Verify(x => x.MoveSelection(NavigationDirection.Right, true));
+                Mock.Get(parentItem).Verify(x => x.Close());
+                Mock.Get(nextItem).Verify(x => x.Open());
+                Mock.Get(nextItem).Verify(x => x.MoveSelection(NavigationDirection.First, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Enter_On_Item_With_No_SubMenu_Causes_Click()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var e = new KeyEventArgs { Key = Key.Enter, Source = item };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.RaiseClick());
+                Mock.Get(menu).Verify(x => x.Close());
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Enter_On_Item_With_SubMenu_Opens_SubMenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true));
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void Escape_Closes_Parent_MenuItem()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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 };
+
+                target.KeyDown(item, e);
+
+                Mock.Get(parentItem).Verify(x => x.Close());
+                Mock.Get(parentItem).Verify(x => x.Focus());
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            public void PointerEnter_Selects_Item()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
+
+                target.PointerEnter(item, e);
+
+                Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item);
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerEnter_Opens_Submenu_After_Delay()
+            {
+                var timer = new TestTimer();
+                var target = new DefaultMenuInteractionHandler(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);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
+
+                target.PointerEnter(item, e);
+                Mock.Get(item).Verify(x => x.Open(), Times.Never);
+
+                timer.Pulse();
+                Mock.Get(item).Verify(x => x.Open());
+
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerEnter_Closes_Sibling_Submenu_After_Delay()
+            {
+                var timer = new TestTimer();
+                var target = new DefaultMenuInteractionHandler(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);
+                var sibling = Mock.Of<IMenuItem>(x => x.Parent == parentItem && x.HasSubMenu == true && x.IsSubMenuOpen == true);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
+
+                Mock.Get(parentItem).SetupGet(x => x.SubItems).Returns(new[] { item, sibling });
+
+                target.PointerEnter(item, e);
+                Mock.Get(sibling).Verify(x => x.Close(), Times.Never);
+
+                timer.Pulse();
+                Mock.Get(sibling).Verify(x => x.Close());
+
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerLeave_Deselects_Item()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
+
+                Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(item);
+                target.PointerLeave(item, e);
+
+                Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null);
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerLeave_Doesnt_Deselect_Sibling()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var sibling = Mock.Of<IMenuItem>(x => x.Parent == parentItem);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
+
+                Mock.Get(parentItem).SetupGet(x => x.SelectedItem).Returns(sibling);
+                target.PointerLeave(item, e);
+
+                Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never);
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerLeave_Doesnt_Deselect_Item_If_Pointer_Over_Submenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var e = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
+
+                target.PointerLeave(item, e);
+
+                Mock.Get(parentItem).VerifySet(x => x.SelectedItem = null, Times.Never);
+                Assert.False(e.Handled);
+            }
+
+            [Fact]
+            public void PointerReleased_On_Item_With_No_SubMenu_Causes_Click()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var e = new PointerReleasedEventArgs { MouseButton = MouseButton.Left, Source = item };
+
+                target.PointerReleased(item, e);
+
+                Mock.Get(item).Verify(x => x.RaiseClick());
+                Mock.Get(menu).Verify(x => x.Close());
+                Assert.True(e.Handled);
+            }
+
+            [Fact]
+            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 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);
+                var childItem = Mock.Of<IMenuItem>(x => x.Parent == item);
+                var enter = new PointerEventArgs { RoutedEvent = MenuItem.PointerEnterItemEvent, Source = item };
+                var leave = new PointerEventArgs { RoutedEvent = MenuItem.PointerLeaveItemEvent, Source = item };
+
+                // Pointer enters item; item is selected.
+                target.PointerEnter(item, enter);
+                Assert.True(timer.ActionIsQueued);
+                Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item);
+                Mock.Get(parentItem).ResetCalls();
+
+                // SubMenu shown after a delay.
+                timer.Pulse();
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).SetupGet(x => x.IsSubMenuOpen).Returns(true);
+                Mock.Get(item).ResetCalls();
+
+                // Pointer briefly exits item, but submenu remains open.
+                target.PointerLeave(item, leave);
+                Mock.Get(item).Verify(x => x.Close(), Times.Never);
+                Mock.Get(item).ResetCalls();
+
+                // Pointer enters child item; is selected.
+                enter.Source = childItem;
+                target.PointerEnter(childItem, enter);
+                Mock.Get(item).VerifySet(x => x.SelectedItem = childItem);
+                Mock.Get(parentItem).VerifySet(x => x.SelectedItem = item);
+                Mock.Get(item).ResetCalls();
+                Mock.Get(parentItem).ResetCalls();
+            }
+
+            [Fact]
+            public void PointerPressed_On_Item_With_SubMenu_Causes_Opens_Submenu()
+            {
+                var target = new DefaultMenuInteractionHandler();
+                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);
+                var e = new PointerPressedEventArgs { MouseButton = MouseButton.Left, Source = item };
+
+                target.PointerPressed(item, e);
+
+                Mock.Get(item).Verify(x => x.Open());
+                Mock.Get(item).Verify(x => x.MoveSelection(NavigationDirection.First, true), Times.Never);
+                Assert.True(e.Handled);
+            }
+        }
+
+        private class TestTimer
+        {
+            private Action _action;
+
+            public bool ActionIsQueued => _action != null;
+
+            public void Pulse()
+            {
+                _action();
+                _action = null;
+            }
+
+            public void RunOnce(Action action, TimeSpan timeSpan)
+            {
+                if (_action != null)
+                {
+                    throw new NotSupportedException("Action already set.");
+                }
+
+                _action = action;
+            }
+        }
+    }
+}

+ 1 - 1
tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs

@@ -219,7 +219,7 @@ namespace Avalonia.Controls.UnitTests
                 scrollable.Setup(x => x.IsLogicalScrollEnabled).Returns(true);
 
                 ((ISetLogicalParent)target).SetParent(presenter.Object);
-                ((INavigableContainer)target).GetControl(NavigationDirection.Next, from);
+                ((INavigableContainer)target).GetControl(NavigationDirection.Next, from, false);
 
                 scrollable.Verify(x => x.GetControlInDirection(NavigationDirection.Next, from));
             }

+ 0 - 799
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs

@@ -1,799 +0,0 @@
-// Copyright (c) The Avalonia Project. All rights reserved.
-// Licensed under the MIT license. See licence.md file in the project root for full license information.
-
-using Avalonia.Controls;
-using Xunit;
-
-namespace Avalonia.Input.UnitTests
-{
-    public class KeyboardNavigationTests_Arrows
-    {
-        [Fact]
-        public void Down_Continue_Returns_Down_Control_In_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            (current = new Button { Name = "Button2" }),
-                            (next = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Continue_Returns_First_Control_In_Down_Sibling_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            new Button { Name = "Button2" },
-                            (current = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            (next = new Button { Name = "Button4" }),
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Continue_Returns_Down_Sibling()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            new Button { Name = "Button2" },
-                            (current = new Button { Name = "Button3" }),
-                        }
-                    },
-                    (next = new Button { Name = "Button4" }),
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Continue_Returns_First_Control_In_Down_Uncle_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        Children =
-                        {
-                            new StackPanel
-                            {
-                                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                                Children =
-                                {
-                                    new Button { Name = "Button1" },
-                                    new Button { Name = "Button2" },
-                                    (current = new Button { Name = "Button3" }),
-                                }
-                            },
-                        },
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            (next = new Button { Name = "Button4" }),
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Continue_Returns_Child_Of_Top_Level()
-        {
-            Button next;
-
-            var top = new StackPanel
-            {
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                Children =
-                {
-                    (next = new Button { Name = "Button1" }),
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(top, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Continue_Wraps()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new StackPanel
-                            {
-                                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                                Children =
-                                {
-                                    (next = new Button { Name = "Button1" }),
-                                    new Button { Name = "Button2" },
-                                    new Button { Name = "Button3" },
-                                }
-                            },
-                        },
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            (current = new Button { Name = "Button6" }),
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Cycle_Returns_Down_Control_In_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            (current = new Button { Name = "Button2" }),
-                            (next = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Cycle_Wraps_To_First()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-                        Children =
-                        {
-                            (next = new Button { Name = "Button1" }),
-                            new Button { Name = "Button2" },
-                            (current = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Contained_Returns_Down_Control_In_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            (current = new Button { Name = "Button2" }),
-                            (next = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Down_Contained_Stops_At_End()
-        {
-            Button current;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            new Button { Name = "Button2" },
-                            (current = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Null(result);
-        }
-
-        [Fact]
-        public void Down_None_Does_Nothing()
-        {
-            Button current;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.None,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            (current = new Button { Name = "Button2" }),
-                            new Button { Name = "Button3" },
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down);
-
-            Assert.Null(result);
-        }
-
-        [Fact]
-        public void Up_Continue_Returns_Up_Control_In_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            (next = new Button { Name = "Button2" }),
-                            (current = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Continue_Returns_Last_Control_In_Up_Sibling_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            new Button { Name = "Button2" },
-                            (next = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            (current = new Button { Name = "Button4" }),
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Continue_Returns_Last_Child_Of_Sibling()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button1" },
-                            new Button { Name = "Button2" },
-                            (next = new Button { Name = "Button3" }),
-                        }
-                    },
-                    (current = new Button { Name = "Button4" }),
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Continue_Returns_Last_Control_In_Up_Nephew_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        Children =
-                        {
-                            new StackPanel
-                            {
-                                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                                Children =
-                                {
-                                    new Button { Name = "Button1" },
-                                    new Button { Name = "Button2" },
-                                    (next = new Button { Name = "Button3" }),
-                                }
-                            },
-                        },
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            (current = new Button { Name = "Button4" }),
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Continue_Wraps()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        Children =
-                        {
-                            new StackPanel
-                            {
-                                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                                Children =
-                                {
-                                    (current = new Button { Name = "Button1" }),
-                                    new Button { Name = "Button2" },
-                                    new Button { Name = "Button3" },
-                                }
-                            },
-                        },
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            (next = new Button { Name = "Button6" }),
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Continue_Returns_Parent()
-        {
-            Button current;
-
-            var top = new Decorator
-            {
-                Focusable = true,
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-                Child = current = new Button
-                {
-                    Name = "Button",
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(top, result);
-        }
-
-        [Fact]
-        public void Up_Cycle_Returns_Up_Control_In_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-                        Children =
-                        {
-                            (next = new Button { Name = "Button1" }),
-                            (current = new Button { Name = "Button2" }),
-                            new Button { Name = "Button3" },
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Cycle_Wraps_To_Last()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-                        Children =
-                        {
-                            (current = new Button { Name = "Button1" }),
-                            new Button { Name = "Button2" },
-                            (next = new Button { Name = "Button3" }),
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Cycle,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Contained_Returns_Up_Control_In_Container()
-        {
-            Button current;
-            Button next;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            (next = new Button { Name = "Button1" }),
-                            (current = new Button { Name = "Button2" }),
-                            new Button { Name = "Button3" },
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Equal(next, result);
-        }
-
-        [Fact]
-        public void Up_Contained_Stops_At_Beginning()
-        {
-            Button current;
-
-            var top = new StackPanel
-            {
-                Children =
-                {
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            (current = new Button { Name = "Button1" }),
-                            new Button { Name = "Button2" },
-                            new Button { Name = "Button3" },
-                        }
-                    },
-                    new StackPanel
-                    {
-                        [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                        Children =
-                        {
-                            new Button { Name = "Button4" },
-                            new Button { Name = "Button5" },
-                            new Button { Name = "Button6" },
-                        }
-                    },
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Null(result);
-        }
-
-        [Fact]
-        public void Up_Contained_Doesnt_Return_Child_Control()
-        {
-            Decorator current;
-
-            var top = new StackPanel
-            {
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Contained,
-                Children =
-                {
-                    (current = new Decorator
-                    {
-                        Focusable = true,
-                        Child = new Button(),
-                    })
-                }
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up);
-
-            Assert.Null(result);
-        }
-    }
-}

+ 0 - 31
tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs

@@ -140,37 +140,6 @@ namespace Avalonia.Input.UnitTests
             Assert.Same(next, result);
         }
 
-        [Fact]
-        public void Right_Should_Custom_Navigate_From_Outside()
-        {
-            Button current;
-            Button next;
-            var target = new CustomNavigatingStackPanel
-            {
-                Children =
-                {
-                    new Button { Content = "Button 1" },
-                    new Button { Content = "Button 2" },
-                    (next = new Button { Content = "Button 3" }),
-                },
-                NextControl = next,
-            };
-
-            var root = new StackPanel
-            {
-                Children =
-                {
-                    (current = new Button { Content = "Outside" }),
-                    target,
-                },
-                [KeyboardNavigation.DirectionalNavigationProperty] = KeyboardNavigationMode.Continue,
-            };
-
-            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
-
-            Assert.Same(next, result);
-        }
-
         [Fact]
         public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next()
         {