diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index 5b046ab682..2abccd6b93 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -665,7 +665,7 @@ namespace Avalonia.Input if (IsValidTabStopSearchCandidate(parent) && parent is InputElement p && KeyboardNavigation.GetTabNavigation(p) == KeyboardNavigationMode.Once) { current = parent; - parent = FocusHelpers.GetFocusParent(focused); + parent = FocusHelpers.GetFocusParent(current); if (parent == null) break; } @@ -752,7 +752,10 @@ namespace Avalonia.Input { if (IsValidTabStopSearchCandidate(current) && current is InputElement c && KeyboardNavigation.GetTabNavigation(c) == KeyboardNavigationMode.Cycle) { - newTabStop = GetFirstFocusableElement(current, current); + // Wrapping backwards inside a Cycle scope lands on the LAST focusable + // element, mirroring the forward wrap (last -> first). + // Matches WinUI: GetLastFocusableElement(pCurrent, pCurrent). + newTabStop = GetLastFocusableElement(current, current); break; } @@ -765,7 +768,7 @@ namespace Avalonia.Input else { current = parent; - parent = FocusHelpers.GetFocusParent(focused); + parent = FocusHelpers.GetFocusParent(current); if (parent == null) break; } @@ -803,7 +806,7 @@ namespace Avalonia.Input else { current = parent; - parent = FocusHelpers.GetFocusParent(focused); + parent = FocusHelpers.GetFocusParent(current); if (parent == null) break; } @@ -928,7 +931,11 @@ namespace Avalonia.Input } else { - if (compareIndexResult < 0 || (((foundCurrent || currentPassed) || compareCurrentForPreviousElement) && compareIndexResult == 0)) + // A candidate with an equal tab index is "previous" only while the walk has not + // passed the focused element yet; candidates from a nested container scan + // (compareCurrentForPreviousElement) already enforced the ordering themselves. + // Matches WinUI: (((!bFoundCurrent && !bCurrentPassed) || bCurrentCompare) && ...) + if (compareIndexResult < 0 || (((!foundCurrent && !currentPassed) || compareCurrentForPreviousElement) && compareIndexResult == 0)) { if (newTabStop != null) { diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index dd2d77ca82..30fd19862e 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -912,6 +912,157 @@ namespace Avalonia.Base.UnitTests.Input } } + [Fact] + public void Can_Get_Next_Element_Out_Of_Container_With_TabNavigation_Once() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var inside = new Button { Focusable = true, Content = "inside" }; + var after = new Button { Focusable = true, Content = "after" }; + + // The focused element has to sit at least one level below the Once container: + // GetFocusParent(focused) must resolve to something *deeper* than that container, + // otherwise the reset below happens to land on the correct node and the walk + // terminates by accident. + var once = new StackPanel + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Once, + Children = + { + new StackPanel { Children = { inside } } + } + }; + var root = new TestRoot + { + Child = new StackPanel + { + Children = { once, after } + } + }; + + var focusManager = FocusManager.GetFocusManager(inside); + Assert.NotNull(focusManager); + inside.Focus(); + + // Before the fix this call never returned: on every Once hit the parent walk was + // reset to the focused element's parent, so it oscillated between the same two + // nodes forever and burned 100% CPU on the UI thread. + var next = focusManager.FindNextElement(NavigationDirection.Next); + + Assert.Equal(after, next); + } + } + + [Fact] + public void Can_Get_Previous_Element_Out_Of_Container_With_TabNavigation_Once() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var before = new Button { Name = "before", Focusable = true, Content = "before" }; + var inside = new Button { Name = "inside", Focusable = true, Content = "inside" }; + + // Same shape as the Next case. The Once container itself must stay unfocusable, + // otherwise GetPreviousTabStop returns it before reaching the faulty branch. + var once = new StackPanel + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Once, + Children = + { + new StackPanel { Children = { inside } } + } + }; + var root = new TestRoot + { + Child = new StackPanel + { + Children = { before, once } + } + }; + + var focusManager = FocusManager.GetFocusManager(inside); + Assert.NotNull(focusManager); + inside.Focus(); + + // Before the fixes this call never returned. The walk must leave the Once + // container and land on the element preceding it. + var previous = focusManager.FindNextElement(NavigationDirection.Previous); + + Assert.Equal(before, previous); + } + } + + [Fact] + public void Can_Get_Previous_Element() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var target4 = new Button { Focusable = true, Content = "4" }; + var container = new StackPanel + { + Children = + { + target1, + target2, + target3, + target4 + } + }; + var root = new TestRoot + { + Child = container + }; + + var focusManager = FocusManager.GetFocusManager(container); + Assert.NotNull(focusManager); + target3.Focus(); + + // Must return the closest preceding sibling: not target1 (which merely comes + // first) and not target4 (which comes after the focused element). + var previous = focusManager.FindNextElement(NavigationDirection.Previous); + + Assert.Equal(target2, previous); + } + } + + [Fact] + public void Previous_Wraps_To_Last_Element_In_Cycle_Container() + { + using (UnitTestApplication.Start(TestServices.RealFocus)) + { + var target1 = new Button { Focusable = true, Content = "1" }; + var target2 = new Button { Focusable = true, Content = "2" }; + var target3 = new Button { Focusable = true, Content = "3" }; + var cycle = new StackPanel + { + [KeyboardNavigation.TabNavigationProperty] = KeyboardNavigationMode.Cycle, + Children = + { + target1, + target2, + target3 + } + }; + var root = new TestRoot + { + Child = cycle + }; + + var focusManager = FocusManager.GetFocusManager(target1); + Assert.NotNull(focusManager); + target1.Focus(); + + // Wrapping backwards inside a Cycle scope must land on the last focusable + // element, mirroring the forward wrap (last -> first). It used to take the + // FIRST element - the focused element itself - making Previous a no-op. + var previous = focusManager.FindNextElement(NavigationDirection.Previous); + + Assert.Equal(target3, previous); + } + } + [Fact] public void Can_Get_Next_Element_With_FocusedElement_Option() {