diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 2bdc37537a..2586333f20 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -58,6 +58,9 @@ namespace Avalonia.Controls static ListBox() { ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); + KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue( + typeof(ListBox), + KeyboardNavigationMode.Once); } /// diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index ac0262c9b2..4df423dee8 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -35,6 +35,9 @@ namespace Avalonia.Controls static Menu() { ItemsPanelProperty.OverrideDefaultValue(typeof(Menu), DefaultPanel); + KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue( + typeof(Menu), + KeyboardNavigationMode.Once); AutomationProperties.AccessibilityViewProperty.OverrideDefaultValue(AccessibilityView.Control); AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Menu); } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index 88d1e10ba4..9610be088b 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -52,13 +52,6 @@ namespace Avalonia.Controls.Presenters nameof(VerticalSnapPointsChanged), RoutingStrategies.Bubble); - static ItemsPresenter() - { - KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue( - typeof(ItemsPresenter), - KeyboardNavigationMode.Once); - } - event EventHandler? ILogicalScrollable.ScrollInvalidated { add => _scrollInvalidated += value; diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 663a315732..af82a89517 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -5,6 +5,7 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Xml.Linq; using Avalonia.Controls.Selection; using Avalonia.Controls.Utils; using Avalonia.Data; @@ -528,13 +529,6 @@ namespace Avalonia.Controls.Primitives protected internal override void ClearContainerForItemOverride(Control element) { base.ClearContainerForItemOverride(element); - - if (Presenter?.Panel is InputElement panel && - KeyboardNavigation.GetTabOnceActiveElement(panel) == element) - { - KeyboardNavigation.SetTabOnceActiveElement(panel, null); - } - element.ClearValue(IsSelectedProperty); } @@ -834,12 +828,6 @@ namespace Avalonia.Controls.Primitives Selection.Clear(); Selection.Select(index); } - - if (Presenter?.Panel is { } panel) - { - var container = ContainerFromIndex(index); - KeyboardNavigation.SetTabOnceActiveElement(panel, container); - } } /// @@ -928,6 +916,7 @@ namespace Avalonia.Controls.Primitives if (e.PropertyName == nameof(ISelectionModel.AnchorIndex)) { _hasScrolledToSelectedItem = false; + KeyboardNavigation.SetTabOnceActiveElement(this, ContainerFromIndex(Selection.AnchorIndex)); AutoScrollToSelectedItemIfNecessary(); } else if (e.PropertyName == nameof(ISelectionModel.SelectedIndex) && _oldSelectedIndex != SelectedIndex) diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index 0f06294d25..b86624ead8 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -225,6 +225,20 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { ItemsPresenterPart = e.NameScope.Get("PART_ItemsPresenter"); + ItemsPresenterPart?.ApplyTemplate(); + + // Set TabNavigation to Once on the panel if not already set and + // forward the TabOnceActiveElement to the panel. + if (ItemsPresenterPart?.Panel is { } panel) + { + if (!panel.IsSet(KeyboardNavigation.TabNavigationProperty)) + panel.SetCurrentValue( + KeyboardNavigation.TabNavigationProperty, + KeyboardNavigationMode.Once); + KeyboardNavigation.SetTabOnceActiveElement( + panel, + KeyboardNavigation.GetTabOnceActiveElement(this)); + } } /// @@ -268,7 +282,17 @@ namespace Avalonia.Controls base.OnPropertyChanged(change); if (change.Property == TabStripPlacementProperty) + { RefreshContainers(); + } + else if (change.Property == KeyboardNavigation.TabOnceActiveElementProperty && + ItemsPresenterPart?.Panel is { } panel) + { + // Forward TabOnceActiveElement to the panel. + KeyboardNavigation.SetTabOnceActiveElement( + panel, + change.GetNewValue()); + } } } } diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index 84eed5ec82..72f476a3b0 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -752,16 +752,6 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(Enumerable.Range(0, 10).Select(x => $"Item{10 - x}"), realized); } - private static void RaiseKeyEvent(ListBox listBox, Key key, KeyModifiers inputModifiers = 0) - { - listBox.RaiseEvent(new KeyEventArgs - { - RoutedEvent = InputElement.KeyDownEvent, - KeyModifiers = inputModifiers, - Key = key - }); - } - [Fact] public void WrapSelection_Should_Wrap() { @@ -948,6 +938,117 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, raised); } + [Fact] + public void Tab_Navigation_Should_Move_To_First_Item_When_No_Anchor_Element_Selected() + { + var services = TestServices.StyledWindow.With( + focusManager: new FocusManager(), + keyboardDevice: () => new KeyboardDevice()); + using var app = UnitTestApplication.Start(services); + + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + var button = new Button + { + Content = "Button", + [DockPanel.DockProperty] = Dock.Top, + }; + + var root = new TestRoot + { + Child = new DockPanel + { + Children = + { + button, + target, + } + } + }; + + var navigation = new KeyboardNavigationHandler(); + navigation.SetOwner(root); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + button.Focus(); + RaiseKeyEvent(button, Key.Tab); + + var item = target.ContainerFromIndex(0); + Assert.Same(item, FocusManager.Instance.Current); + } + + [Fact] + public void Tab_Navigation_Should_Move_To_Anchor_Element() + { + var services = TestServices.StyledWindow.With( + focusManager: new FocusManager(), + keyboardDevice: () => new KeyboardDevice()); + using var app = UnitTestApplication.Start(services); + + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = { "Foo", "Bar", "Baz" }, + }; + + var button = new Button + { + Content = "Button", + [DockPanel.DockProperty] = Dock.Top, + }; + + var root = new TestRoot + { + Width = 1000, + Height = 1000, + Child = new DockPanel + { + Children = + { + button, + target, + } + } + }; + + var navigation = new KeyboardNavigationHandler(); + navigation.SetOwner(root); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + button.Focus(); + target.Selection.AnchorIndex = 1; + RaiseKeyEvent(button, Key.Tab); + + var item = target.ContainerFromIndex(1); + Assert.Same(item, FocusManager.Instance.Current); + + RaiseKeyEvent(item, Key.Tab); + + Assert.Same(button, FocusManager.Instance.Current); + + target.Selection.AnchorIndex = 2; + RaiseKeyEvent(button, Key.Tab); + + item = target.ContainerFromIndex(2); + Assert.Same(item, FocusManager.Instance.Current); + } + + private static void RaiseKeyEvent(Control target, Key key, KeyModifiers inputModifiers = 0) + { + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + KeyModifiers = inputModifiers, + Key = key + }); + } + private record ItemViewModel(string Caption); private class ResettingCollection : List, INotifyCollectionChanged diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index dfdcd09bf9..9d2ffb1fa2 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -1236,13 +1236,13 @@ namespace Avalonia.Controls.UnitTests.Primitives }; AvaloniaLocator.CurrentMutable.Bind().ToConstant(new Mock().Object); Prepare(target); - _helper.Down((Interactive)target.Presenter.Panel.Children[1]); + + var container = target.ContainerFromIndex(1)!; + _helper.Down(container); var panel = target.Presenter.Panel; - Assert.Equal( - KeyboardNavigation.GetTabOnceActiveElement((InputElement)panel), - panel.Children[1]); + Assert.Same(container, KeyboardNavigation.GetTabOnceActiveElement(target)); } } diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 7440ee4858..15ba871e8c 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -5,8 +5,10 @@ using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Selection; using Avalonia.Controls.Templates; using Avalonia.Controls.Utils; +using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Markup.Xaml; using Avalonia.Styling; @@ -413,6 +415,117 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Tab_Navigation_Should_Move_To_First_TabItem_When_No_Anchor_Element_Selected() + { + var services = TestServices.StyledWindow.With( + focusManager: new FocusManager(), + keyboardDevice: () => new KeyboardDevice()); + using var app = UnitTestApplication.Start(services); + + var target = new TabControl + { + Template = TabControlTemplate(), + Items = + { + new TabItem { Header = "foo" }, + new TabItem { Header = "bar" }, + new TabItem { Header = "baz" }, + } + }; + + var button = new Button + { + Content = "Button", + [DockPanel.DockProperty] = Dock.Top, + }; + + var root = new TestRoot + { + Child = new DockPanel + { + Children = + { + button, + target, + } + } + }; + + var navigation = new KeyboardNavigationHandler(); + navigation.SetOwner(root); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + button.Focus(); + RaiseKeyEvent(button, Key.Tab); + + var item = target.ContainerFromIndex(0); + Assert.Same(item, FocusManager.Instance.Current); + } + + [Fact] + public void Tab_Navigation_Should_Move_To_Anchor_TabItem() + { + var services = TestServices.StyledWindow.With( + focusManager: new FocusManager(), + keyboardDevice: () => new KeyboardDevice()); + using var app = UnitTestApplication.Start(services); + + var target = new TestTabControl + { + Template = TabControlTemplate(), + Items = + { + new TabItem { Header = "foo" }, + new TabItem { Header = "bar" }, + new TabItem { Header = "baz" }, + } + }; + + var button = new Button + { + Content = "Button", + [DockPanel.DockProperty] = Dock.Top, + }; + + var root = new TestRoot + { + Width = 1000, + Height = 1000, + Child = new DockPanel + { + Children = + { + button, + target, + } + } + }; + + var navigation = new KeyboardNavigationHandler(); + navigation.SetOwner(root); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + button.Focus(); + target.Selection.AnchorIndex = 1; + RaiseKeyEvent(button, Key.Tab); + + var item = target.ContainerFromIndex(1); + Assert.Same(item, FocusManager.Instance.Current); + + RaiseKeyEvent(item, Key.Tab); + + Assert.Same(button, FocusManager.Instance.Current); + + target.Selection.AnchorIndex = 2; + RaiseKeyEvent(button, Key.Tab); + + item = target.ContainerFromIndex(2); + Assert.Same(item, FocusManager.Instance.Current); + } + private static IControlTemplate TabControlTemplate() { return new FuncControlTemplate((parent, scope) => @@ -452,6 +565,16 @@ namespace Avalonia.Controls.UnitTests target.Arrange(new Rect(target.DesiredSize)); } + private static void RaiseKeyEvent(Control target, Key key, KeyModifiers inputModifiers = 0) + { + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + KeyModifiers = inputModifiers, + Key = key + }); + } + private static void ApplyTemplate(TabControl target) { target.ApplyTemplate(); @@ -479,5 +602,11 @@ namespace Avalonia.Controls.UnitTests public string Value { get; } } + + private class TestTabControl : TabControl, IStyleable + { + Type IStyleable.StyleKey => typeof(TabControl); + public new ISelectionModel Selection => base.Selection; + } } }