Jelajahi Sumber

Merge branch 'master' into DisableButtonOnNullCommandBinding

Steven Kirk 8 tahun lalu
induk
melakukan
92dd602a64

+ 23 - 13
src/Avalonia.Controls/Button.cs

@@ -211,7 +211,11 @@ namespace Avalonia.Controls
         /// <param name="e">The event args.</param>
         protected virtual void OnClick(RoutedEventArgs e)
         {
-            Command?.Execute(CommandParameter);
+            if (Command != null)
+            {
+                Command.Execute(CommandParameter);
+                e.Handled = true;
+            }
         }
 
         /// <inheritdoc/>
@@ -219,13 +223,16 @@ namespace Avalonia.Controls
         {
             base.OnPointerPressed(e);
 
-            PseudoClasses.Add(":pressed");
-            e.Device.Capture(this);
-            e.Handled = true;
-
-            if (ClickMode == ClickMode.Press)
+            if (e.MouseButton == MouseButton.Left)
             {
-                RaiseClickEvent();
+                PseudoClasses.Add(":pressed");
+                e.Device.Capture(this);
+                e.Handled = true;
+
+                if (ClickMode == ClickMode.Press)
+                {
+                    RaiseClickEvent();
+                }
             }
         }
 
@@ -234,13 +241,16 @@ namespace Avalonia.Controls
         {
             base.OnPointerReleased(e);
 
-            e.Device.Capture(null);
-            PseudoClasses.Remove(":pressed");
-            e.Handled = true;
-
-            if (ClickMode == ClickMode.Release && new Rect(Bounds.Size).Contains(e.GetPosition(this)))
+            if (e.MouseButton == MouseButton.Left)
             {
-                RaiseClickEvent();
+                e.Device.Capture(null);
+                PseudoClasses.Remove(":pressed");
+                e.Handled = true;
+
+                if (ClickMode == ClickMode.Release && new Rect(Bounds.Size).Contains(e.GetPosition(this)))
+                {
+                    RaiseClickEvent();
+                }
             }
         }
 

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

@@ -47,7 +47,7 @@ namespace Avalonia.Controls
         static Menu()
         {
             ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel);
-            MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick);
+            MenuItem.ClickEvent.AddClassHandler<Menu>(x => x.OnMenuClick, handledEventsToo: true);
             MenuItem.SubmenuOpenedEvent.AddClassHandler<Menu>(x => x.OnSubmenuOpened);
         }
 

+ 10 - 1
src/Avalonia.Controls/MenuItem.cs

@@ -102,6 +102,11 @@ namespace Avalonia.Controls
             AccessKeyHandler.AccessKeyPressedEvent.AddClassHandler<MenuItem>(x => x.AccessKeyPressed);
         }
 
+        public MenuItem()
+        {
+
+        }
+
         /// <summary>
         /// Occurs when a <see cref="MenuItem"/> without a submenu is clicked.
         /// </summary>
@@ -192,7 +197,11 @@ namespace Avalonia.Controls
         /// <param name="e">The click event args.</param>
         protected virtual void OnClick(RoutedEventArgs e)
         {
-            Command?.Execute(CommandParameter);
+            if (Command != null)
+            {
+                Command.Execute(CommandParameter);
+                e.Handled = true;
+            }
         }
 
         /// <summary>

+ 21 - 1
src/Avalonia.Controls/TreeView.cs

@@ -16,7 +16,7 @@ namespace Avalonia.Controls
     /// <summary>
     /// Displays a hierachical tree of data.
     /// </summary>
-    public class TreeView : ItemsControl
+    public class TreeView : ItemsControl, ICustomKeyboardNavigation
     {
         /// <summary>
         /// Defines the <see cref="AutoScrollToSelectedItem"/> property.
@@ -90,6 +90,26 @@ namespace Avalonia.Controls
             }
         }
 
+        (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element, NavigationDirection direction)
+        {
+            if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
+            {
+                if (!this.IsVisualAncestorOf(element))
+                {
+                    IControl result = _selectedItem != null ?
+                        ItemContainerGenerator.Index.ContainerFromItem(_selectedItem) :
+                        ItemContainerGenerator.ContainerFromIndex(0);
+                    return (true, result);
+                }
+                else
+                {
+                    return (true, null);
+                }
+            }
+
+            return (false, null);
+        }
+
         /// <inheritdoc/>
         protected override IItemContainerGenerator CreateItemContainerGenerator()
         {

+ 3 - 0
src/Avalonia.Input/Avalonia.Input.csproj

@@ -37,5 +37,8 @@
       <Link>Properties\SharedAssemblyInfo.cs</Link>
     </Compile>
   </ItemGroup>
+  <ItemGroup>
+    <PackageReference Include="System.ValueTuple" Version="4.3.1" />
+  </ItemGroup>
   <Import Project="..\..\build\Rx.props" />
 </Project>

+ 3 - 2
src/Avalonia.Input/FocusManager.cs

@@ -176,9 +176,10 @@ namespace Avalonia.Input
         /// <param name="e">The event args.</param>
         private void OnPreviewPointerPressed(object sender, RoutedEventArgs e)
         {
-            if (sender == e.Source)
+            var ev = (PointerPressedEventArgs)e;
+
+            if (sender == e.Source && ev.MouseButton == MouseButton.Left)
             {
-                var ev = (PointerPressedEventArgs)e;
                 var element = (ev.Device?.Captured as IInputElement) ?? (e.Source as IInputElement);
 
                 if (element == null || !CanFocus(element))

+ 15 - 0
src/Avalonia.Input/ICustomKeyboardNavigation.cs

@@ -0,0 +1,15 @@
+// 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;
+
+namespace Avalonia.Input
+{
+    /// <summary>
+    /// Designates a control as handling its own keyboard navigation.
+    /// </summary>
+    public interface ICustomKeyboardNavigation
+    {
+        (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction);
+    }
+}

+ 27 - 0
src/Avalonia.Input/KeyboardNavigationHandler.cs

@@ -2,7 +2,9 @@
 // Licensed under the MIT license. See licence.md file in the project root for full license information.
 
 using System;
+using System.Linq;
 using Avalonia.Input.Navigation;
+using Avalonia.VisualTree;
 
 namespace Avalonia.Input
 {
@@ -52,6 +54,31 @@ namespace Avalonia.Input
         {
             Contract.Requires<ArgumentNullException>(element != null);
 
+            var customHandler = element.GetSelfAndVisualAncestors()
+                .OfType<ICustomKeyboardNavigation>()
+                .FirstOrDefault();
+
+            if (customHandler != null)
+            {
+                var (handled, next) = customHandler.GetNext(element, direction);
+
+                if (handled)
+                {
+                    if (next != null)
+                    {
+                        return next;
+                    }
+                    else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
+                    {
+                        return TabNavigation.GetNextInTabOrder((IInputElement)customHandler, direction, true);
+                    }
+                    else
+                    {
+                        return null;
+                    }
+                }
+            }
+
             if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous)
             {
                 return TabNavigation.GetNextInTabOrder(element, direction);

+ 14 - 2
src/Avalonia.Input/Navigation/DirectionalNavigation.cs

@@ -41,7 +41,7 @@ namespace Avalonia.Input.Navigation
                 {
                     case KeyboardNavigationMode.Continue:
                         return GetNextInContainer(element, container, direction) ??
-                               GetFirstInNextContainer(element, direction);
+                               GetFirstInNextContainer(element, element, direction);
                     case KeyboardNavigationMode.Cycle:
                         return GetNextInContainer(element, container, direction) ??
                                GetFocusableDescendant(container, direction);
@@ -173,10 +173,12 @@ namespace Avalonia.Input.Navigation
         /// <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)
         {
@@ -200,6 +202,16 @@ namespace Avalonia.Input.Navigation
 
                 if (sibling != null)
                 {
+                    if (sibling is ICustomKeyboardNavigation custom)
+                    {
+                        var (handled, customNext) = custom.GetNext(element, direction);
+
+                        if (handled)
+                        {
+                            return customNext;
+                        }
+                    }
+
                     if (sibling.CanFocus())
                     {
                         next = sibling;
@@ -214,7 +226,7 @@ namespace Avalonia.Input.Navigation
 
                 if (next == null)
                 {
-                    next = GetFirstInNextContainer(parent, direction);
+                    next = GetFirstInNextContainer(element, parent, direction);
                 }
             }
             else

+ 62 - 25
src/Avalonia.Input/Navigation/TabNavigation.cs

@@ -18,13 +18,17 @@ namespace Avalonia.Input.Navigation
         /// </summary>
         /// <param name="element">The element.</param>
         /// <param name="direction">The tab direction. Must be Next or Previous.</param>
+        /// <param name="outsideElement">
+        /// If true will not descend into <paramref name="element"/> to find next control.
+        /// </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 GetNextInTabOrder(
             IInputElement element,
-            NavigationDirection direction)
+            NavigationDirection direction,
+            bool outsideElement = false)
         {
             Contract.Requires<ArgumentNullException>(element != null);
             Contract.Requires<ArgumentException>(
@@ -40,20 +44,20 @@ namespace Avalonia.Input.Navigation
                 switch (mode)
                 {
                     case KeyboardNavigationMode.Continue:
-                        return GetNextInContainer(element, container, direction) ??
-                               GetFirstInNextContainer(element, direction);
+                        return GetNextInContainer(element, container, direction, outsideElement) ??
+                               GetFirstInNextContainer(element, element, direction);
                     case KeyboardNavigationMode.Cycle:
-                        return GetNextInContainer(element, container, direction) ??
+                        return GetNextInContainer(element, container, direction, outsideElement) ??
                                GetFocusableDescendant(container, direction);
                     case KeyboardNavigationMode.Contained:
-                        return GetNextInContainer(element, container, direction);
+                        return GetNextInContainer(element, container, direction, outsideElement);
                     default:
-                        return GetFirstInNextContainer(container, direction);
+                        return GetFirstInNextContainer(element, container, direction);
                 }
             }
             else
             {
-                return GetFocusableDescendants(element).FirstOrDefault();
+                return GetFocusableDescendants(element, direction).FirstOrDefault();
             }
         }
 
@@ -66,16 +70,17 @@ namespace Avalonia.Input.Navigation
         private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction)
         {
             return direction == NavigationDirection.Next ?
-                GetFocusableDescendants(container).FirstOrDefault() :
-                GetFocusableDescendants(container).LastOrDefault();
+                GetFocusableDescendants(container, direction).FirstOrDefault() :
+                GetFocusableDescendants(container, direction).LastOrDefault();
         }
 
         /// <summary>
         /// Gets the focusable descendants of the specified element.
         /// </summary>
         /// <param name="element">The element.</param>
+        /// <param name="direction">The tab direction. Must be Next or Previous.</param>
         /// <returns>The element's focusable descendants.</returns>
-        private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element)
+        private static IEnumerable<IInputElement> GetFocusableDescendants(IInputElement element, NavigationDirection direction)
         {
             var mode = KeyboardNavigation.GetTabNavigation((InputElement)element);
 
@@ -103,16 +108,25 @@ namespace Avalonia.Input.Navigation
 
             foreach (var child in children)
             {
-                if (child.CanFocus())
+                var customNext = GetCustomNext(child, direction);
+
+                if (customNext.handled)
                 {
-                    yield return child;
+                    yield return customNext.next;
                 }
-
-                if (child.CanFocusDescendants())
+                else
                 {
-                    foreach (var descendant in GetFocusableDescendants(child))
+                    if (child.CanFocus())
                     {
-                        yield return descendant;
+                        yield return child;
+                    }
+
+                    if (child.CanFocusDescendants())
+                    {
+                        foreach (var descendant in GetFocusableDescendants(child, direction))
+                        {
+                            yield return descendant;
+                        }
                     }
                 }
             }
@@ -124,15 +138,19 @@ namespace Avalonia.Input.Navigation
         /// <param name="element">The starting element/</param>
         /// <param name="container">The container.</param>
         /// <param name="direction">The direction.</param>
+        /// <param name="outsideElement">
+        /// If true will not descend into <paramref name="element"/> to find next control.
+        /// </param>
         /// <returns>The next element, or null if the element is the last.</returns>
         private static IInputElement GetNextInContainer(
             IInputElement element,
             IInputElement container,
-            NavigationDirection direction)
+            NavigationDirection direction,
+            bool outsideElement)
         {
-            if (direction == NavigationDirection.Next)
+            if (direction == NavigationDirection.Next && !outsideElement)
             {
-                var descendant = GetFocusableDescendants(element).FirstOrDefault();
+                var descendant = GetFocusableDescendants(element, direction).FirstOrDefault();
 
                 if (descendant != null)
                 {
@@ -167,7 +185,7 @@ namespace Avalonia.Input.Navigation
 
                 if (element != null && direction == NavigationDirection.Previous)
                 {
-                    var descendant = GetFocusableDescendants(element).LastOrDefault();
+                    var descendant = GetFocusableDescendants(element, direction).LastOrDefault();
 
                     if (descendant != null)
                     {
@@ -184,10 +202,12 @@ namespace Avalonia.Input.Navigation
         /// <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)
         {
@@ -210,6 +230,13 @@ namespace Avalonia.Input.Navigation
 
                 if (sibling != null)
                 {
+                    var customNext = GetCustomNext(sibling, direction);
+
+                    if (customNext.handled)
+                    {
+                        return customNext.next;
+                    }
+
                     if (sibling.CanFocus())
                     {
                         next = sibling;
@@ -217,24 +244,34 @@ namespace Avalonia.Input.Navigation
                     else
                     {
                         next = direction == NavigationDirection.Next ?
-                            GetFocusableDescendants(sibling).FirstOrDefault() :
-                            GetFocusableDescendants(sibling).LastOrDefault();
+                            GetFocusableDescendants(sibling, direction).FirstOrDefault() :
+                            GetFocusableDescendants(sibling, direction).LastOrDefault();
                     }
                 }
 
                 if (next == null)
                 {
-                    next = GetFirstInNextContainer(parent, direction);
+                    next = GetFirstInNextContainer(element, parent, direction);
                 }
             }
             else
             {
                 next = direction == NavigationDirection.Next ?
-                    GetFocusableDescendants(container).FirstOrDefault() :
-                    GetFocusableDescendants(container).LastOrDefault();
+                    GetFocusableDescendants(container, direction).FirstOrDefault() :
+                    GetFocusableDescendants(container, direction).LastOrDefault();
             }
 
             return next;
         }
+
+        private static (bool handled, IInputElement next) GetCustomNext(IInputElement element, NavigationDirection direction)
+        {
+            if (element is ICustomKeyboardNavigation custom)
+            {
+                return custom.GetNext(element, direction);
+            }
+
+            return (false, null);
+        }
     }
 }

+ 2 - 2
src/Avalonia.Styling/Styling/Style.cs

@@ -61,12 +61,12 @@ namespace Avalonia.Styling
         }
 
         /// <summary>
-        /// Gets or sets style's selector.
+        /// Gets or sets the style's selector.
         /// </summary>
         public Selector Selector { get; set; }
 
         /// <summary>
-        /// Gets or sets style's setters.
+        /// Gets or sets the style's setters.
         /// </summary>
         [Content]
         public IEnumerable<ISetter> Setters { get; set; } = new List<ISetter>();

+ 44 - 0
tests/Avalonia.Controls.UnitTests/TreeViewTests.cs

@@ -315,6 +315,50 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(new[] { "NewChild1" }, ExtractItemHeader(target, 1));
         }
 
+        [Fact]
+        public void Keyboard_Navigation_Should_Move_To_Last_Selected_Node()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var focus = FocusManager.Instance;
+                var navigation = AvaloniaLocator.Current.GetService<IKeyboardNavigationHandler>();
+                var data = CreateTestTreeData();
+
+                var target = new TreeView
+                {
+                    Template = CreateTreeViewTemplate(),
+                    Items = data,
+                    DataTemplates = CreateNodeDataTemplate(),
+                };
+
+                var button = new Button();
+
+                var root = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children = { target, button },
+                    }
+                };
+
+                ApplyTemplates(target);
+
+                var item = data[0].Children[0];
+                var node = target.ItemContainerGenerator.Index.ContainerFromItem(item);
+                Assert.NotNull(node);
+
+                target.SelectedItem = item;
+                node.Focus();
+                Assert.Same(node, focus.Current);
+
+                navigation.Move(focus.Current, NavigationDirection.Next);
+                Assert.Same(button, focus.Current);
+
+                navigation.Move(focus.Current, NavigationDirection.Next);
+                Assert.Same(node, focus.Current);
+            }
+        }
+
         private void ApplyTemplates(TreeView tree)
         {
             tree.ApplyTemplate();

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

@@ -0,0 +1,214 @@
+// 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_Custom
+    {
+        [Fact]
+        public void Tab_Should_Custom_Navigate_Within_Children()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Button 1" }),
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Right_Should_Custom_Navigate_Within_Children()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    (current = new Button { Content = "Button 1" }),
+                    new Button { Content = "Button 2" },
+                    (next = new Button { Content = "Button 3" }),
+                },
+                NextControl = next,
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Right);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Tab_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,
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void Tab_Should_Custom_Navigate_From_Outside_When_Wrapping()
+        {
+            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 =
+                {
+                    target,
+                    (current = new Button { Content = "Outside" }),
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        [Fact]
+        public void ShiftTab_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,
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous);
+
+            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()
+        {
+            Button current;
+            Button next;
+            var target = new CustomNavigatingStackPanel
+            {
+                Children =
+                {
+                    new Button { Content = "Button 1" },
+                    (current = new Button { Content = "Button 2" }),
+                    new Button { Content = "Button 3" },
+                },
+            };
+
+            var root = new StackPanel
+            {
+                Children =
+                {
+                    target,
+                    (next = new Button { Content = "Outside" }),
+                }
+            };
+
+            var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next);
+
+            Assert.Same(next, result);
+        }
+
+        private class CustomNavigatingStackPanel : StackPanel, ICustomKeyboardNavigation
+        {
+            public bool CustomNavigates { get; set; } = true;
+            public IInputElement NextControl { get; set; }
+
+            public (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction)
+            {
+                return (CustomNavigates, NextControl);
+            }
+        }
+    }
+}

+ 1 - 0
tests/Avalonia.RenderTests/Media/BitmapTests.cs

@@ -81,6 +81,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media
             using (var target = r.CreateRenderTarget(new object[] { fb }))
             using (var ctx = target.CreateDrawingContext(null))
             {
+                ctx.Clear(Colors.Transparent);
                 ctx.PushOpacity(0.8);
                 ctx.FillRectangle(Brushes.Chartreuse, new Rect(0, 0, 20, 100));
                 ctx.FillRectangle(Brushes.Crimson, new Rect(20, 0, 20, 100));

+ 6 - 0
tests/Avalonia.UnitTests/TestServices.cs

@@ -50,6 +50,7 @@ namespace Avalonia.UnitTests
         public static readonly TestServices RealFocus = new TestServices(
             focusManager: new FocusManager(),
             keyboardDevice: () => new KeyboardDevice(),
+            keyboardNavigation: new KeyboardNavigationHandler(),
             inputManager: new InputManager());
 
         public static readonly TestServices RealLayoutManager = new TestServices(
@@ -63,6 +64,7 @@ namespace Avalonia.UnitTests
             IFocusManager focusManager = null,
             IInputManager inputManager = null,
             Func<IKeyboardDevice> keyboardDevice = null,
+            IKeyboardNavigationHandler keyboardNavigation = null,
             ILayoutManager layoutManager = null,
             IRuntimePlatform platform = null,
             Func<IRenderRoot, IRenderLoop, IRenderer> renderer = null,
@@ -79,6 +81,7 @@ namespace Avalonia.UnitTests
             FocusManager = focusManager;
             InputManager = inputManager;
             KeyboardDevice = keyboardDevice;
+            KeyboardNavigation = keyboardNavigation;
             LayoutManager = layoutManager;
             Platform = platform;
             Renderer = renderer;
@@ -96,6 +99,7 @@ namespace Avalonia.UnitTests
         public IInputManager InputManager { get; }
         public IFocusManager FocusManager { get; }
         public Func<IKeyboardDevice> KeyboardDevice { get; }
+        public IKeyboardNavigationHandler KeyboardNavigation { get; }
         public ILayoutManager LayoutManager { get; }
         public IRuntimePlatform Platform { get; }
         public Func<IRenderRoot, IRenderLoop, IRenderer> Renderer { get; }
@@ -113,6 +117,7 @@ namespace Avalonia.UnitTests
             IFocusManager focusManager = null,
             IInputManager inputManager = null,
             Func<IKeyboardDevice> keyboardDevice = null,
+            IKeyboardNavigationHandler keyboardNavigation = null,
             ILayoutManager layoutManager = null,
             IRuntimePlatform platform = null,
             Func<IRenderRoot, IRenderLoop, IRenderer> renderer = null,
@@ -131,6 +136,7 @@ namespace Avalonia.UnitTests
                 focusManager: focusManager ?? FocusManager,
                 inputManager: inputManager ?? InputManager,
                 keyboardDevice: keyboardDevice ?? KeyboardDevice,
+                keyboardNavigation: keyboardNavigation ?? KeyboardNavigation,
                 layoutManager: layoutManager ?? LayoutManager,
                 platform: platform ?? Platform,
                 renderer: renderer ?? Renderer,

+ 1 - 0
tests/Avalonia.UnitTests/UnitTestApplication.cs

@@ -49,6 +49,7 @@ namespace Avalonia.UnitTests
                 .BindToSelf<IGlobalStyles>(this)
                 .Bind<IInputManager>().ToConstant(Services.InputManager)
                 .Bind<IKeyboardDevice>().ToConstant(Services.KeyboardDevice?.Invoke())
+                .Bind<IKeyboardNavigationHandler>().ToConstant(Services.KeyboardNavigation)
                 .Bind<ILayoutManager>().ToConstant(Services.LayoutManager)
                 .Bind<IRuntimePlatform>().ToConstant(Services.Platform)
                 .Bind<IRendererFactory>().ToConstant(new RendererFactory(Services.Renderer))