diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 96729c2c29d..5d9a0c8eed5 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -90,6 +90,7 @@ public abstract class TopLevel : ContentControl, /// static TopLevel() { + KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); AffectsMeasure(ClientSizeProperty); TransparencyLevelHintProperty.Changed.AddClassHandler( diff --git a/src/Avalonia.Input/Avalonia.Input.csproj b/src/Avalonia.Input/Avalonia.Input.csproj index c39c81a9658..69a80290d17 100644 --- a/src/Avalonia.Input/Avalonia.Input.csproj +++ b/src/Avalonia.Input/Avalonia.Input.csproj @@ -4,6 +4,9 @@ Enable CS8600;CS8602;CS8603 + + + diff --git a/src/Avalonia.Input/FocusManager.cs b/src/Avalonia.Input/FocusManager.cs index 474b212a21d..1432092ba13 100644 --- a/src/Avalonia.Input/FocusManager.cs +++ b/src/Avalonia.Input/FocusManager.cs @@ -92,6 +92,17 @@ public void Focus( } } + public IInputElement? GetFocusedElement(IInputElement e) + { + if (e is IFocusScope scope) + { + _focusScopes.TryGetValue(scope, out var result); + return result; + } + + return null; + } + /// /// Sets the currently focused element in the specified scope. /// @@ -151,6 +162,8 @@ public void SetFocusScope(IFocusScope scope) Focus(e); } + public static bool GetIsFocusScope(IInputElement e) => e is IFocusScope; + /// /// Checks if the specified element can be focused. /// diff --git a/src/Avalonia.Input/ICustomKeyboardNavigation.cs b/src/Avalonia.Input/ICustomKeyboardNavigation.cs index 3d2927c6328..357395c42f8 100644 --- a/src/Avalonia.Input/ICustomKeyboardNavigation.cs +++ b/src/Avalonia.Input/ICustomKeyboardNavigation.cs @@ -1,4 +1,5 @@ - +#nullable enable + namespace Avalonia.Input { /// @@ -6,6 +7,18 @@ namespace Avalonia.Input /// public interface ICustomKeyboardNavigation { - (bool handled, IInputElement next) GetNext(IInputElement element, NavigationDirection direction); + /// + /// Gets the next element in the specified navigation direction. + /// + /// The element being navigated from. + /// The navigation direction. + /// + /// A tuple consisting of: + /// - A boolean indicating whether the request was handled. If false is returned then + /// custom navigation will be ignored and default navigation will take place. + /// - If handled is true: the next element in the navigation direction, or null if default + /// navigation should continue outside the element. + /// + (bool handled, IInputElement? next) GetNext(IInputElement element, NavigationDirection direction); } } diff --git a/src/Avalonia.Input/InputElement.cs b/src/Avalonia.Input/InputElement.cs index 65b9acae762..63080e74e4f 100644 --- a/src/Avalonia.Input/InputElement.cs +++ b/src/Avalonia.Input/InputElement.cs @@ -71,6 +71,12 @@ public class InputElement : Interactive, IInputElement public static readonly DirectProperty IsPointerOverProperty = AvaloniaProperty.RegisterDirect(nameof(IsPointerOver), o => o.IsPointerOver); + /// + /// Defines the property. + /// + public static readonly StyledProperty IsTabStopProperty = + KeyboardNavigation.IsTabStopProperty.AddOwner(); + /// /// Defines the event. /// @@ -99,6 +105,12 @@ public class InputElement : Interactive, IInputElement "KeyUp", RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + /// + /// Defines the property. + /// + public static readonly StyledProperty TabIndexProperty = + KeyboardNavigation.TabIndexProperty.AddOwner(); + /// /// Defines the event. /// @@ -426,6 +438,15 @@ public bool IsPointerOver internal set { SetAndRaise(IsPointerOverProperty, ref _isPointerOver, value); } } + /// + /// Gets or sets a value that indicates whether the control is included in tab navigation. + /// + public bool IsTabStop + { + get => GetValue(IsTabStopProperty); + set => SetValue(IsTabStopProperty, value); + } + /// public bool IsEffectivelyEnabled { @@ -437,6 +458,16 @@ private set } } + /// + /// Gets or sets a value that determines the order in which elements receive focus when the + /// user navigates through controls by pressing the Tab key. + /// + public int TabIndex + { + get => GetValue(TabIndexProperty); + set => SetValue(TabIndexProperty, value); + } + public List KeyBindings { get; } = new List(); /// diff --git a/src/Avalonia.Input/KeyboardNavigation.cs b/src/Avalonia.Input/KeyboardNavigation.cs index 6ef3c4fd60d..a25aed6811a 100644 --- a/src/Avalonia.Input/KeyboardNavigation.cs +++ b/src/Avalonia.Input/KeyboardNavigation.cs @@ -5,6 +5,15 @@ namespace Avalonia.Input /// public static class KeyboardNavigation { + /// + /// Defines the TabIndex attached property. + /// + public static readonly AttachedProperty TabIndexProperty = + AvaloniaProperty.RegisterAttached( + "TabIndex", + typeof(KeyboardNavigation), + int.MaxValue); + /// /// Defines the TabNavigation attached property. /// @@ -42,6 +51,26 @@ public static class KeyboardNavigation typeof(KeyboardNavigation), true); + /// + /// Gets the for an element. + /// + /// The container. + /// The for the container. + public static int GetTabIndex(IInputElement element) + { + return ((IAvaloniaObject)element).GetValue(TabIndexProperty); + } + + /// + /// Sets the for an element. + /// + /// The element. + /// The tab index. + public static void SetTabIndex(IInputElement element, int value) + { + ((IAvaloniaObject)element).SetValue(TabIndexProperty, value); + } + /// /// Gets the for a container. /// @@ -83,7 +112,7 @@ public static void SetTabOnceActiveElement(InputElement element, IInputElement? } /// - /// Sets the for a container. + /// Sets the for an element. /// /// The container. /// Value indicating whether the container is a tab stop. @@ -93,7 +122,7 @@ public static void SetIsTabStop(InputElement element, bool value) } /// - /// Gets the for a container. + /// Gets the for an element. /// /// The container. /// Whether the container is a tab stop. diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs index dbefe63789b..64937771056 100644 --- a/src/Avalonia.Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Input/KeyboardNavigationHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Avalonia.Input.Navigation; using Avalonia.VisualTree; @@ -48,39 +49,24 @@ public void SetOwner(IInputRoot owner) { element = element ?? throw new ArgumentNullException(nameof(element)); - var customHandler = element.GetSelfAndVisualAncestors() - .OfType() - .FirstOrDefault(); + // If there's a custom keyboard navigation handler as an ancestor, use that. + var custom = element.FindAncestorOfType(true); + if (custom is object && HandlePreCustomNavigation(custom, element, direction, out var ce)) + return ce; - if (customHandler != null) + var result = direction switch { - var (handled, next) = customHandler.GetNext(element, direction); + NavigationDirection.Next => TabNavigation.GetNextTab(element, false), + NavigationDirection.Previous => TabNavigation.GetPrevTab(element, null, false), + _ => throw new NotSupportedException(), + }; - 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 there wasn't a custom navigation handler as an ancestor of the current element, + // but there is one as an ancestor of the new element, use that. + if (custom is null && HandlePostCustomNavigation(element, result, direction, out ce)) + return ce; - if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) - { - return TabNavigation.GetNextInTabOrder(element, direction); - } - else - { - throw new NotSupportedException(); - } + return result; } /// @@ -90,7 +76,7 @@ public void SetOwner(IInputRoot owner) /// The direction to move. /// Any key modifiers active at the time of focus. public void Move( - IInputElement element, + IInputElement element, NavigationDirection direction, KeyModifiers keyModifiers = KeyModifiers.None) { @@ -124,5 +110,70 @@ protected virtual void OnKeyDown(object sender, KeyEventArgs e) e.Handled = true; } } + + private static bool HandlePreCustomNavigation( + ICustomKeyboardNavigation customHandler, + IInputElement element, + NavigationDirection direction, + [NotNullWhen(true)] out IInputElement? result) + { + if (customHandler != null) + { + var (handled, next) = customHandler.GetNext(element, direction); + + if (handled) + { + if (next != null) + { + result = next; + return true; + } + else if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) + { + var r = direction switch + { + NavigationDirection.Next => TabNavigation.GetNextTabOutside(customHandler), + NavigationDirection.Previous => TabNavigation.GetPrevTabOutside(customHandler), + _ => throw new NotSupportedException(), + }; + + if (r is object) + { + result = r; + return true; + } + } + } + } + + result = null; + return false; + } + + private static bool HandlePostCustomNavigation( + IInputElement element, + IInputElement? newElement, + NavigationDirection direction, + [NotNullWhen(true)] out IInputElement? result) + { + if (newElement is object) + { + var customHandler = newElement.FindAncestorOfType(true); + + if (customHandler is object) + { + var (handled, next) = customHandler.GetNext(element, direction); + + if (handled && next is object) + { + result = next; + return true; + } + } + } + + result = null; + return false; + } } } diff --git a/src/Avalonia.Input/KeyboardNavigationMode.cs b/src/Avalonia.Input/KeyboardNavigationMode.cs index 41e778bf498..e01ebf03300 100644 --- a/src/Avalonia.Input/KeyboardNavigationMode.cs +++ b/src/Avalonia.Input/KeyboardNavigationMode.cs @@ -36,5 +36,10 @@ public enum KeyboardNavigationMode /// The container's children will not be focused when using the tab key. /// None, + + /// + /// TabIndexes are considered on local subtree only inside this container + /// + Local, } -} \ No newline at end of file +} diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index 6f6d68940ba..ed7df67bf22 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -10,277 +10,663 @@ namespace Avalonia.Input.Navigation /// internal static class TabNavigation { - /// - /// Gets the next control in the specified tab direction. - /// - /// The element. - /// The tab direction. Must be Next or Previous. - /// - /// If true will not descend into to find next control. - /// - /// - /// The next element in the specified direction, or null if - /// was the last in the requested direction. - /// - public static IInputElement? GetNextInTabOrder( - IInputElement element, - NavigationDirection direction, - bool outsideElement = false) + public static IInputElement? GetNextTab(IInputElement e, bool goDownOnly) { - element = element ?? throw new ArgumentNullException(nameof(element)); - - if (direction != NavigationDirection.Next && direction != NavigationDirection.Previous) - { - throw new ArgumentException("Invalid direction: must be Next or Previous."); - } + return GetNextTab(e, GetGroupParent(e), goDownOnly); + } - var container = element.GetVisualParent(); + public static IInputElement? GetNextTab(IInputElement? e, IInputElement container, bool goDownOnly) + { + var tabbingType = GetKeyNavigationMode(container); - if (container != null) + if (e == null) { - var mode = KeyboardNavigation.GetTabNavigation((InputElement)container); + if (IsTabStop(container)) + return container; - switch (mode) + // Using ActiveElement if set + var activeElement = GetActiveElement(container); + if (activeElement != null) + return GetNextTab(null, activeElement, true); + } + else + { + if (tabbingType == KeyboardNavigationMode.Once || tabbingType == KeyboardNavigationMode.None) { - case KeyboardNavigationMode.Continue: - return GetNextInContainer(element, container, direction, outsideElement) ?? - GetFirstInNextContainer(element, element, direction); - case KeyboardNavigationMode.Cycle: - return GetNextInContainer(element, container, direction, outsideElement) ?? - GetFocusableDescendant(container, direction); - case KeyboardNavigationMode.Contained: - return GetNextInContainer(element, container, direction, outsideElement); - default: - return GetFirstInNextContainer(element, container, direction); + if (container != e) + { + if (goDownOnly) + return null; + var parentContainer = GetGroupParent(container); + return GetNextTab(container, parentContainer, goDownOnly); + } } } - else + + // All groups + IInputElement? loopStartElement = null; + var nextTabElement = e; + var currentTabbingType = tabbingType; + + // Search down inside the container + while ((nextTabElement = GetNextTabInGroup(nextTabElement, container, currentTabbingType)) != null) { - return GetFocusableDescendants(element, direction).FirstOrDefault(); + // Avoid the endless loop here for Cycle groups + if (loopStartElement == nextTabElement) + break; + if (loopStartElement == null) + loopStartElement = nextTabElement; + + var firstTabElementInside = GetNextTab(null, nextTabElement, true); + if (firstTabElementInside != null) + return firstTabElementInside; + + // If we want to continue searching inside the Once groups, we should change the navigation mode + if (currentTabbingType == KeyboardNavigationMode.Once) + currentTabbingType = KeyboardNavigationMode.Contained; } + + // If there is no next element in the group (nextTabElement == null) + + // Search up in the tree if allowed + // consider: Use original tabbingType instead of currentTabbingType + if (!goDownOnly && currentTabbingType != KeyboardNavigationMode.Contained && GetParent(container) != null) + { + return GetNextTab(container, GetGroupParent(container), false); + } + + return null; } - /// - /// Gets the first or last focusable descendant of the specified element. - /// - /// The element. - /// The direction to search. - /// The element or null if not found.## - private static IInputElement GetFocusableDescendant(IInputElement container, NavigationDirection direction) + public static IInputElement? GetNextTabOutside(ICustomKeyboardNavigation e) { - return direction == NavigationDirection.Next ? - GetFocusableDescendants(container, direction).FirstOrDefault() : - GetFocusableDescendants(container, direction).LastOrDefault(); + if (e is IInputElement container) + { + var last = GetLastInTree(container); + + if (last is object) + return GetNextTab(last, false); + } + + return null; } - /// - /// Gets the focusable descendants of the specified element. - /// - /// The element. - /// The tab direction. Must be Next or Previous. - /// The element's focusable descendants. - private static IEnumerable GetFocusableDescendants(IInputElement element, - NavigationDirection direction) + public static IInputElement? GetPrevTab(IInputElement? e, IInputElement? container, bool goDownOnly) { - var mode = KeyboardNavigation.GetTabNavigation((InputElement)element); + if (e is null && container is null) + throw new InvalidOperationException("Either 'e' or 'container' must be non-null."); - if (mode == KeyboardNavigationMode.None) - { - yield break; - } + if (container is null) + container = GetGroupParent(e!); - var children = element.GetVisualChildren().OfType(); + KeyboardNavigationMode tabbingType = GetKeyNavigationMode(container); - if (mode == KeyboardNavigationMode.Once) + if (e == null) { - var active = KeyboardNavigation.GetTabOnceActiveElement((InputElement)element); - - if (active != null) + // Using ActiveElement if set + var activeElement = GetActiveElement(container); + if (activeElement != null) + return GetPrevTab(null, activeElement, true); + else { - yield return active; - yield break; + // If we Shift+Tab on a container with KeyboardNavigationMode=Once, and ActiveElement is null + // then we want to go to the first item (not last) within the container + if (tabbingType == KeyboardNavigationMode.Once) + { + var firstTabElement = GetNextTabInGroup(null, container, tabbingType); + if (firstTabElement == null) + { + if (IsTabStop(container)) + return container; + if (goDownOnly) + return null; + + return GetPrevTab(container, null, false); + } + else + { + return GetPrevTab(null, firstTabElement, true); + } + } } - else + } + else + { + if (tabbingType == KeyboardNavigationMode.Once || tabbingType == KeyboardNavigationMode.None) { - children = children.Take(1); + if (goDownOnly || container == e) + return null; + + // FocusedElement should not be e otherwise we will delegate focus to the same element + if (IsTabStop(container)) + return container; + + return GetPrevTab(container, null, false); } } - foreach (var child in children) + // All groups (except Once) - continue + IInputElement? loopStartElement = null; + IInputElement? nextTabElement = e; + + // Look for element with the same TabIndex before the current element + while ((nextTabElement = GetPrevTabInGroup(nextTabElement, container, tabbingType)) != null) { - var customNext = GetCustomNext(child, direction); + if (nextTabElement == container && tabbingType == KeyboardNavigationMode.Local) + break; + + // At this point nextTabElement is TabStop or TabGroup + // In case it is a TabStop only return the element + if (IsTabStop(nextTabElement) && !IsGroup(nextTabElement)) + return nextTabElement; + + // Avoid the endless loop here + if (loopStartElement == nextTabElement) + break; + if (loopStartElement == null) + loopStartElement = nextTabElement; + + // At this point nextTabElement is TabGroup + var lastTabElementInside = GetPrevTab(null, nextTabElement, true); + if (lastTabElementInside != null) + return lastTabElementInside; + } - if (customNext.handled) - { - yield return customNext.next!; - } - else + if (tabbingType == KeyboardNavigationMode.Contained) + return null; + + if (e != container && IsTabStop(container)) + return container; + + // If end of the subtree is reached or there no other elements above + if (!goDownOnly && GetParent(container) != null) + { + return GetPrevTab(container, null, false); + } + + return null; + } + + public static IInputElement? GetPrevTabOutside(ICustomKeyboardNavigation e) + { + if (e is IInputElement container) + { + var first = GetFirstChild(container); + + if (first is object) + return GetPrevTab(first, null, false); + } + + return null; + } + + private static IInputElement? FocusedElement(IInputElement e) + { + var iie = e; + // Focus delegation is enabled only if keyboard focus is outside the container + if (iie != null && !iie.IsKeyboardFocusWithin) + { + var focusedElement = (FocusManager.Instance as FocusManager)?.GetFocusedElement(e); + if (focusedElement != null) { - if (child.CanFocus() && KeyboardNavigation.GetIsTabStop((InputElement)child)) + if (!IsFocusScope(e)) { - yield return child; + // Verify if focusedElement is a visual descendant of e + if (focusedElement is IVisual visualFocusedElement && + visualFocusedElement != e && + e.IsVisualAncestorOf(visualFocusedElement)) + { + return focusedElement; + } } + } + } + + return null; + } + + private static IInputElement? GetFirstChild(IInputElement e) + { + // If the element has a FocusedElement it should be its first child + if (FocusedElement(e) is IInputElement focusedElement) + return focusedElement; - if (child.CanFocusDescendants()) + // Return the first visible element. + var uiElement = e as InputElement; + + if (uiElement is null || uiElement.IsVisible) + { + if (e is IVisual elementAsVisual) + { + var children = elementAsVisual.VisualChildren; + var count = children.Count; + + for (int i = 0; i < count; i++) { - foreach (var descendant in GetFocusableDescendants(child, direction)) + if (children[i] is InputElement ie) { - if (KeyboardNavigation.GetIsTabStop((InputElement)descendant)) + if (ie.IsVisible) + return ie; + else { - yield return descendant; + var firstChild = GetFirstChild(ie); + if (firstChild != null) + return firstChild; } } } } } + + return null; } - /// - /// Gets the next item that should be focused in the specified container. - /// - /// The starting element/ - /// The container. - /// The direction. - /// - /// If true will not descend into to find next control. - /// - /// The next element, or null if the element is the last. - private static IInputElement? GetNextInContainer( - IInputElement element, - IInputElement container, - NavigationDirection direction, - bool outsideElement) + private static IInputElement? GetLastChild(IInputElement e) { - IInputElement? e = element; + // If the element has a FocusedElement it should be its last child + if (FocusedElement(e) is IInputElement focusedElement) + return focusedElement; - if (direction == NavigationDirection.Next && !outsideElement) - { - var descendant = GetFocusableDescendants(element, direction).FirstOrDefault(); + // Return the last visible element. + var uiElement = e as InputElement; - if (descendant != null) - { - return descendant; - } - } - - if (container != null) + if (uiElement == null || uiElement.IsVisible) { - var navigable = container as INavigableContainer; + var elementAsVisual = e as IVisual; - // TODO: Do a spatial search here if the container doesn't implement - // INavigableContainer. - if (navigable != null) + if (elementAsVisual != null) { - while (e != null) - { - e = navigable.GetControl(direction, e, false); + var children = elementAsVisual.VisualChildren; + var count = children.Count; - if (e != null && - e.CanFocus() && - KeyboardNavigation.GetIsTabStop((InputElement)e)) + for (int i = count - 1; i >= 0; i--) + { + if (children[i] is InputElement ie) { - break; + if (ie.IsVisible) + return ie; + else + { + var lastChild = GetLastChild(ie); + if (lastChild != null) + return lastChild; + } } } } - else + } + + return null; + } + + private static IInputElement? GetFirstTabInGroup(IInputElement container) + { + IInputElement? firstTabElement = null; + int minIndexFirstTab = int.MinValue; + + var currElement = container; + while ((currElement = GetNextInTree(currElement, container)) != null) + { + if (IsTabStopOrGroup(currElement)) { - // TODO: Do a spatial search here if the container doesn't implement - // INavigableContainer. - e = null; + int currPriority = KeyboardNavigation.GetTabIndex(currElement); + + if (currPriority < minIndexFirstTab || firstTabElement == null) + { + minIndexFirstTab = currPriority; + firstTabElement = currElement; + } } + } + return firstTabElement; + } + + private static IInputElement? GetLastInTree(IInputElement container) + { + IInputElement? result; + IInputElement? c = container; + + do + { + result = c; + c = GetLastChild(c); + } while (c != null && !IsGroup(c)); + + if (c != null) + return c; + + return result; + } - if (e != null && direction == NavigationDirection.Previous) + private static IInputElement? GetLastTabInGroup(IInputElement container) + { + IInputElement? lastTabElement = null; + int maxIndexFirstTab = int.MaxValue; + var currElement = GetLastInTree(container); + while (currElement != null && currElement != container) + { + if (IsTabStopOrGroup(currElement)) { - var descendant = GetFocusableDescendants(e, direction).LastOrDefault(); + int currPriority = KeyboardNavigation.GetTabIndex(currElement); - if (descendant != null) + if (currPriority > maxIndexFirstTab || lastTabElement == null) { - return descendant; + maxIndexFirstTab = currPriority; + lastTabElement = currElement; } } + currElement = GetPreviousInTree(currElement, container); + } + return lastTabElement; + } + + private static IInputElement? GetNextInTree(IInputElement e, IInputElement container) + { + IInputElement? result = null; + + if (e == container || !IsGroup(e)) + result = GetFirstChild(e); + + if (result != null || e == container) + return result; + + IInputElement? parent = e; + do + { + var sibling = GetNextSibling(parent); + if (sibling != null) + return sibling; - return e; + parent = GetParent(parent); + } while (parent != null && parent != container); + + return null; + } + + private static IInputElement? GetNextSibling(IInputElement e) + { + if (GetParent(e) is IVisual parentAsVisual && e is IVisual elementAsVisual) + { + var children = parentAsVisual.VisualChildren; + var count = children.Count; + var i = 0; + + //go till itself + for (; i < count; i++) + { + var vchild = children[i]; + if (vchild == elementAsVisual) + break; + } + i++; + //search ahead + for (; i < count; i++) + { + var visual = children[i]; + if (visual is IInputElement ie) + return ie; + } } return null; } - /// - /// Gets the first item that should be focused in the next container. - /// - /// The element being navigated away from. - /// The container. - /// The direction of the search. - /// The first element, or null if there are no more elements. - private static IInputElement? GetFirstInNextContainer( - IInputElement element, - IInputElement container, - NavigationDirection direction) + private static IInputElement? GetNextTabInGroup(IInputElement? e, IInputElement container, KeyboardNavigationMode tabbingType) { - var parent = container.GetVisualParent(); - IInputElement? next = null; + // None groups: Tab navigation is not supported + if (tabbingType == KeyboardNavigationMode.None) + return null; - if (parent != null) + // e == null or e == container -> return the first TabStopOrGroup + if (e == null || e == container) { - if (direction == NavigationDirection.Previous && - parent.CanFocus() && - KeyboardNavigation.GetIsTabStop((InputElement) parent)) + return GetFirstTabInGroup(container); + } + + if (tabbingType == KeyboardNavigationMode.Once) + return null; + + var nextTabElement = GetNextTabWithSameIndex(e, container); + if (nextTabElement != null) + return nextTabElement; + + return GetNextTabWithNextIndex(e, container, tabbingType); + } + + private static IInputElement? GetNextTabWithSameIndex(IInputElement e, IInputElement container) + { + var elementTabPriority = KeyboardNavigation.GetTabIndex(e); + var currElement = e; + while ((currElement = GetNextInTree(currElement, container)) != null) + { + if (IsTabStopOrGroup(currElement) && KeyboardNavigation.GetTabIndex(currElement) == elementTabPriority) { - return parent; + return currElement; } + } - var allSiblings = parent.GetVisualChildren() - .OfType() - .Where(FocusExtensions.CanFocusDescendants); - var siblings = direction == NavigationDirection.Next ? - allSiblings.SkipWhile(x => x != container).Skip(1) : - allSiblings.TakeWhile(x => x != container).Reverse(); + return null; + } - foreach (var sibling in siblings) + private static IInputElement? GetNextTabWithNextIndex(IInputElement e, IInputElement container, KeyboardNavigationMode tabbingType) + { + // Find the next min index in the tree + // min (index>currentTabIndex) + IInputElement? nextTabElement = null; + IInputElement? firstTabElement = null; + int minIndexFirstTab = int.MinValue; + int minIndex = int.MinValue; + int elementTabPriority = KeyboardNavigation.GetTabIndex(e); + + IInputElement? currElement = container; + while ((currElement = GetNextInTree(currElement, container)) != null) + { + if (IsTabStopOrGroup(currElement)) { - var customNext = GetCustomNext(sibling, direction); - if (customNext.handled) + int currPriority = KeyboardNavigation.GetTabIndex(currElement); + if (currPriority > elementTabPriority) { - return customNext.next; + if (currPriority < minIndex || nextTabElement == null) + { + minIndex = currPriority; + nextTabElement = currElement; + } } - if (sibling.CanFocus() && KeyboardNavigation.GetIsTabStop((InputElement) sibling)) + if (currPriority < minIndexFirstTab || firstTabElement == null) { - return sibling; + minIndexFirstTab = currPriority; + firstTabElement = currElement; } + } + } - next = direction == NavigationDirection.Next ? - GetFocusableDescendants(sibling, direction).FirstOrDefault() : - GetFocusableDescendants(sibling, direction).LastOrDefault(); + // Cycle groups: if not found - return first element + if (tabbingType == KeyboardNavigationMode.Cycle && nextTabElement == null) + nextTabElement = firstTabElement; + + return nextTabElement; + } - if (next != null) + private static IInputElement? GetPrevTabInGroup(IInputElement? e, IInputElement container, KeyboardNavigationMode tabbingType) + { + // None groups: Tab navigation is not supported + if (tabbingType == KeyboardNavigationMode.None) + return null; + + // Search the last index inside the group + if (e == null) + { + return GetLastTabInGroup(container); + } + + if (tabbingType == KeyboardNavigationMode.Once) + return null; + + if (e == container) + return null; + + var nextTabElement = GetPrevTabWithSameIndex(e, container); + if (nextTabElement != null) + return nextTabElement; + + return GetPrevTabWithPrevIndex(e, container, tabbingType); + } + + private static IInputElement? GetPrevTabWithSameIndex(IInputElement e, IInputElement container) + { + int elementTabPriority = KeyboardNavigation.GetTabIndex(e); + var currElement = GetPreviousInTree(e, container); + while (currElement != null) + { + if (IsTabStopOrGroup(currElement) && KeyboardNavigation.GetTabIndex(currElement) == elementTabPriority && currElement != container) + { + return currElement; + } + currElement = GetPreviousInTree(currElement, container); + } + return null; + } + + private static IInputElement? GetPrevTabWithPrevIndex(IInputElement e, IInputElement container, KeyboardNavigationMode tabbingType) + { + // Find the next max index in the tree + // max (index maxIndex || nextTabElement == null) + { + maxIndex = currPriority; + nextTabElement = currElement; + } + } + + if (currPriority > maxIndexFirstTab || lastTabElement == null) { - return next; + maxIndexFirstTab = currPriority; + lastTabElement = currElement; } } - next = GetFirstInNextContainer(element, parent, direction); + currElement = GetPreviousInTree(currElement, container); + } + + // Cycle groups: if not found - return first element + if (tabbingType == KeyboardNavigationMode.Cycle && nextTabElement == null) + nextTabElement = lastTabElement; + + return nextTabElement; + } + + private static IInputElement? GetPreviousInTree(IInputElement e, IInputElement container) + { + if (e == container) + return null; + + var result = GetPreviousSibling(e); + + if (result != null) + { + if (IsGroup(result)) + return result; + else + return GetLastInTree(result); } else + return GetParent(e); + } + + private static IInputElement? GetPreviousSibling(IInputElement e) + { + if (GetParent(e) is IVisual parentAsVisual && e is IVisual elementAsVisual) { - next = direction == NavigationDirection.Next ? - GetFocusableDescendants(container, direction).FirstOrDefault() : - GetFocusableDescendants(container, direction).LastOrDefault(); + var children = parentAsVisual.VisualChildren; + var count = children.Count; + IInputElement? prev = null; + + for (int i = 0; i < count; i++) + { + var vchild = children[i]; + if (vchild == elementAsVisual) + break; + if (vchild.IsVisible == true && vchild is IInputElement ie) + prev = ie; + } + return prev; } + return null; + } - return next; + private static IInputElement? GetActiveElement(IInputElement e) + { + return ((IAvaloniaObject)e).GetValue(KeyboardNavigation.TabOnceActiveElementProperty); } - private static (bool handled, IInputElement? next) GetCustomNext(IInputElement element, - NavigationDirection direction) + private static IInputElement GetGroupParent(IInputElement e) => GetGroupParent(e, false); + + private static IInputElement GetGroupParent(IInputElement element, bool includeCurrent) { - if (element is ICustomKeyboardNavigation custom) + var result = element; // Keep the last non null element + var e = element; + + // If we don't want to include the current element, + // start at the parent of the element. If the element + // is the root, then just return it as the group parent. + if (!includeCurrent) + { + result = e; + e = GetParent(e); + if (e == null) + return result; + } + + while (e != null) { - return custom.GetNext(element, direction); + if (IsGroup(e)) + return e; + + result = e; + e = GetParent(e); } - return (false, null); + return result; } + + private static IInputElement? GetParent(IInputElement e) + { + // For Visual - go up the visual parent chain until we find Visual. + if (e is IVisual v) + return v.FindAncestorOfType(); + + // This will need to be implemented when we have non-visual input elements. + throw new NotSupportedException(); + } + + private static KeyboardNavigationMode GetKeyNavigationMode(IInputElement e) + { + return ((IAvaloniaObject)e).GetValue(KeyboardNavigation.TabNavigationProperty); + } + private static bool IsFocusScope(IInputElement e) => FocusManager.GetIsFocusScope(e) || GetParent(e) == null; + private static bool IsGroup(IInputElement e) => GetKeyNavigationMode(e) != KeyboardNavigationMode.Continue; + + private static bool IsTabStop(IInputElement e) + { + if (e is InputElement ie) + return ie.Focusable && KeyboardNavigation.GetIsTabStop(ie) && ie.IsVisible && ie.IsEnabled; + return false; + } + + private static bool IsTabStopOrGroup(IInputElement e) => IsTabStop(e) || IsGroup(e); } } diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs index f72d6ba9c96..f9c85ee4ca8 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Custom.cs @@ -95,6 +95,7 @@ public void Tab_Should_Custom_Navigate_From_Outside_When_Wrapping() var root = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { target, @@ -125,6 +126,7 @@ public void ShiftTab_Should_Custom_Navigate_From_Outside() var root = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { (current = new Button { Content = "Outside" }), @@ -137,6 +139,36 @@ public void ShiftTab_Should_Custom_Navigate_From_Outside() Assert.Same(next, result); } + [Fact] + public void ShiftTab_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 + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, + Children = + { + target, + (next = new Button { Content = "Outside" }), + } + }; + + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); + + Assert.Same(next, result); + } + [Fact] public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next() { @@ -154,6 +186,7 @@ public void Tab_Should_Navigate_Outside_When_Null_Returned_As_Next() var root = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { target, diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs index 1efbbed2e85..edcbf75a1d0 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Avalonia.Controls; using Xunit; @@ -13,6 +14,7 @@ public void Next_Continue_Returns_Next_Control_In_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -49,6 +51,7 @@ public void Next_Continue_Returns_First_Control_In_Next_Sibling_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -85,6 +88,7 @@ public void Next_Skips_Unfocusable_Siblings() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -122,6 +126,7 @@ public void Next_Continue_Doesnt_Enter_Panel_With_TabNavigation_None() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -165,6 +170,7 @@ public void Next_Continue_Returns_Next_Sibling() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -193,6 +199,7 @@ public void Next_Skips_Non_TabStop_Siblings() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -222,6 +229,7 @@ public void Next_Continue_Returns_First_Control_In_Next_Uncle_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -263,6 +271,7 @@ public void Next_Continue_Returns_Child_Of_Top_Level() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { (next = new Button { Name = "Button1" }), @@ -282,6 +291,7 @@ public void Next_Continue_Wraps() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -324,6 +334,7 @@ public void Next_Cycle_Returns_Next_Control_In_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -361,6 +372,7 @@ public void Next_Cycle_Wraps_To_First() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -398,6 +410,7 @@ public void Next_Contained_Returns_Next_Control_In_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -434,6 +447,7 @@ public void Next_Contained_Stops_At_End() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -471,6 +485,7 @@ public void Next_Once_Moves_To_Next_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -509,6 +524,7 @@ public void Next_Once_Moves_To_Active_Element() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { (container = new StackPanel @@ -548,6 +564,7 @@ public void Next_None_Moves_To_Next_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -586,6 +603,7 @@ public void Next_None_Skips_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { (container = new StackPanel @@ -625,6 +643,7 @@ public void Previous_Continue_Returns_Previous_Control_In_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -661,6 +680,7 @@ public void Previous_Continue_Returns_Last_Control_In_Previous_Sibling_Container var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -697,6 +717,7 @@ public void Previous_Continue_Returns_Last_Child_Of_Sibling() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -725,6 +746,7 @@ public void Previous_Continue_Returns_Last_Control_In_Previous_Nephew_Container( var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -767,6 +789,7 @@ public void Previous_Continue_Wraps() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -828,6 +851,7 @@ public void Previous_Cycle_Returns_Previous_Control_In_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -865,6 +889,7 @@ public void Previous_Cycle_Wraps_To_Last() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -902,6 +927,7 @@ public void Previous_Contained_Returns_Previous_Control_In_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -938,6 +964,7 @@ public void Previous_Contained_Stops_At_Beginning() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -975,6 +1002,7 @@ public void Previous_Once_Moves_To_Previous_Container() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -1013,6 +1041,7 @@ public void Previous_Once_Moves_To_Active_Element() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { (container = new StackPanel @@ -1052,6 +1081,7 @@ public void Previous_Once_Moves_To_First_Element() var top = new StackPanel { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, Children = { new StackPanel @@ -1103,5 +1133,97 @@ public void Previous_Contained_Doesnt_Select_Child_Control() Assert.Null(result); } + + [Fact] + public void Respects_TabIndex_Moving_Forwards() + { + Button start; + + var top = new StackPanel + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, + Children = + { + new StackPanel + { + Children = + { + new Button { Name = "Button1", TabIndex = 5 }, + (start = new Button { Name = "Button2", TabIndex = 2 }), + new Button { Name = "Button3", TabIndex = 1 }, + } + }, + new StackPanel + { + Children = + { + new Button { Name = "Button4", TabIndex = 3 }, + new Button { Name = "Button5", TabIndex = 6 }, + new Button { Name = "Button6", TabIndex = 4 }, + } + }, + } + }; + + var result = new List(); + var current = (IInputElement)start; + + do + { + result.Add(((IControl)current).Name); + current = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); + } while (current is object && current != start); + + Assert.Equal(new[] + { + "Button2", "Button4", "Button6", "Button1", "Button5", "Button3" + }, result); + } + + [Fact] + public void Respects_TabIndex_Moving_Backwards() + { + Button start; + + var top = new StackPanel + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, + Children = + { + new StackPanel + { + Children = + { + new Button { Name = "Button1", TabIndex = 5 }, + (start = new Button { Name = "Button2", TabIndex = 2 }), + new Button { Name = "Button3", TabIndex = 1 }, + } + }, + new StackPanel + { + Children = + { + new Button { Name = "Button4", TabIndex = 3 }, + new Button { Name = "Button5", TabIndex = 6 }, + new Button { Name = "Button6", TabIndex = 4 }, + } + }, + } + }; + + var result = new List(); + var current = (IInputElement)start; + + do + { + result.Add(((IControl)current).Name); + current = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); + } while (current is object && current != start); + + Assert.Equal(new[] + { + "Button2", "Button3", "Button5", "Button1", "Button6", "Button4" + }, result); + } } } diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index b69bf990d91..4601dd7e5bb 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -21,6 +21,7 @@ public TestRoot() Renderer = Mock.Of(); LayoutManager = new LayoutManager(this); IsVisible = true; + KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle); } public TestRoot(IControl child)