Browse Source

Add keyboard navigation for overflow CommandBar popup (#21005)

* Fix keyboard navigation in CommandBar overflow

* More fixes

* More changes

* More changes, added more tests
release/12.0.0-rc2
Javier Suárez 7 months ago
committed by GitHub
parent
commit
5d5fce8d9b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 121
      samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml
  2. 93
      samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml.cs
  3. 1
      samples/ControlCatalog/Pages/CommandBarPage.xaml.cs
  4. 128
      src/Avalonia.Controls/CommandBar/CommandBar.cs
  5. 5
      src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml
  6. 5
      src/Avalonia.Themes.Simple/Controls/CommandBar.xaml
  7. 223
      tests/Avalonia.Controls.UnitTests/CommandBarTests.cs

121
samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml

@ -0,0 +1,121 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.CommandBarKeyboardPage">
<UserControl.Resources>
<StreamGeometry x:Key="AddIcon">M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z</StreamGeometry>
<StreamGeometry x:Key="SaveIcon">M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z</StreamGeometry>
<StreamGeometry x:Key="BoldIcon">M15.6,10.79C17.04,10.07 18,8.64 18,7C18,4.79 16.21,3 14,3H7V21H14.73C16.78,21 18.5,19.37 18.5,17.32C18.5,15.82 17.72,14.53 16.5,13.77C16.2,13.59 15.9,13.44 15.6,13.32V10.79M10,6.5H13C13.83,6.5 14.5,7.17 14.5,8C14.5,8.83 13.83,9.5 13,9.5H10V6.5M13.5,17.5H10V14H13.5C14.33,14 15,14.67 15,15.5C15,16.33 14.33,17.5 13.5,17.5Z</StreamGeometry>
<StreamGeometry x:Key="ShareIcon">M18,16.08C17.24,16.08 16.56,16.38 16.04,16.85L8.91,12.7C8.96,12.47 9,12.24 9,12C9,11.76 8.96,11.53 8.91,11.3L15.96,7.19C16.5,7.69 17.21,8 18,8A3,3 0 0,0 21,5A3,3 0 0,0 18,2A3,3 0 0,0 15,5C15,5.24 15.04,5.47 15.09,5.7L8.04,9.81C7.5,9.31 6.79,9 6,9A3,3 0 0,0 3,12A3,3 0 0,0 6,15C6.79,15 7.5,14.69 8.04,14.19L15.16,18.34C15.11,18.55 15.08,18.77 15.08,19C15.08,20.61 16.39,21.91 18,21.91C19.61,21.91 20.92,20.61 20.92,19C20.92,17.39 19.61,16.08 18,16.08Z</StreamGeometry>
<StreamGeometry x:Key="DeleteIcon">M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z</StreamGeometry>
</UserControl.Resources>
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="240">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="How to test" FontWeight="SemiBold" FontSize="16"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock FontSize="12" Opacity="0.7" TextWrapping="Wrap"
Text="1. Click &quot;Open overflow&quot; below, or click the ··· button with the mouse." />
<TextBlock FontSize="12" Opacity="0.7" TextWrapping="Wrap"
Text="2. Use Up/Down arrow keys to navigate. Focus wraps from last to first and back." />
<TextBlock FontSize="12" Opacity="0.7" TextWrapping="Wrap"
Text="3. The separator and the disabled Delete item are skipped during navigation." />
<TextBlock FontSize="12" Opacity="0.7" TextWrapping="Wrap"
Text="4. Press Escape to close. Focus returns to the ··· button." />
<TextBlock FontSize="12" Opacity="0.7" TextWrapping="Wrap"
Text="5. You can also Tab into the CommandBar. Focus walks through primary items and the ··· button in order." />
<Separator />
<Button Content="Open overflow"
HorizontalAlignment="Stretch"
Click="OnOpenOverflow" />
<Button Content="Clear Log"
HorizontalAlignment="Stretch"
Click="OnClearLog" />
<Separator />
<TextBlock Text="About" FontWeight="SemiBold" />
<TextBlock FontSize="12" Opacity="0.7" TextWrapping="Wrap"
Text="The focus log updates as keyboard navigation moves between overflow items. Separators and disabled items are excluded from the cycle." />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<ScrollViewer>
<StackPanel Spacing="16" Margin="12,12,12,0">
<TextBlock Classes="h2">Keyboard navigation in the overflow popup: Up/Down to move, Home/End to jump, Escape to close.</TextBlock>
<CommandBar x:Name="DemoBar">
<CommandBar.PrimaryCommands>
<CommandBarButton Label="New">
<CommandBarButton.Icon><PathIcon Data="{StaticResource AddIcon}" /></CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Label="Save">
<CommandBarButton.Icon><PathIcon Data="{StaticResource SaveIcon}" /></CommandBarButton.Icon>
</CommandBarButton>
</CommandBar.PrimaryCommands>
<CommandBar.SecondaryCommands>
<CommandBarButton x:Name="BtnCopy" Label="Copy" />
<CommandBarButton x:Name="BtnPaste" Label="Paste" />
<CommandBarToggleButton x:Name="BtnBold" Label="Bold">
<CommandBarToggleButton.Icon><PathIcon Data="{StaticResource BoldIcon}" /></CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarSeparator />
<CommandBarButton x:Name="BtnShare" Label="Share">
<CommandBarButton.Icon><PathIcon Data="{StaticResource ShareIcon}" /></CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="BtnDelete" Label="Delete" IsEnabled="False">
<CommandBarButton.Icon><PathIcon Data="{StaticResource DeleteIcon}" /></CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="BtnExport" Label="Export" />
</CommandBar.SecondaryCommands>
</CommandBar>
<!-- Keyboard shortcuts reference -->
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="10">
<StackPanel Spacing="6">
<TextBlock Text="Keyboard shortcuts" FontWeight="SemiBold" />
<Grid ColumnDefinitions="80,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" FontFamily="Consolas,Menlo,monospace" FontSize="12" Text="Down" />
<TextBlock Grid.Row="0" Grid.Column="1" FontSize="12" Opacity="0.7" Text="Next item (wraps to first)" />
<TextBlock Grid.Row="1" Grid.Column="0" FontFamily="Consolas,Menlo,monospace" FontSize="12" Text="Up" />
<TextBlock Grid.Row="1" Grid.Column="1" FontSize="12" Opacity="0.7" Text="Previous item (wraps to last)" />
<TextBlock Grid.Row="2" Grid.Column="0" FontFamily="Consolas,Menlo,monospace" FontSize="12" Text="Home" />
<TextBlock Grid.Row="2" Grid.Column="1" FontSize="12" Opacity="0.7" Text="First item" />
<TextBlock Grid.Row="3" Grid.Column="0" FontFamily="Consolas,Menlo,monospace" FontSize="12" Text="End" />
<TextBlock Grid.Row="3" Grid.Column="1" FontSize="12" Opacity="0.7" Text="Last item" />
<TextBlock Grid.Row="4" Grid.Column="0" FontFamily="Consolas,Menlo,monospace" FontSize="12" Text="Escape" />
<TextBlock Grid.Row="4" Grid.Column="1" FontSize="12" Opacity="0.7" Text="Close menu, focus returns to ··· button" />
</Grid>
</StackPanel>
</Border>
<!-- Focus log -->
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="10">
<StackPanel Spacing="6">
<TextBlock Text="Focus log" FontWeight="SemiBold" />
<TextBlock x:Name="FocusLogText"
FontFamily="Consolas, Menlo, monospace"
FontSize="12"
TextWrapping="Wrap"
MinHeight="120"
Opacity="0.8"
Text="Open the overflow menu and use keyboard navigation." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>

93
samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml.cs

@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
namespace ControlCatalog.Pages
{
public partial class CommandBarKeyboardPage : UserControl
{
private readonly List<string> _log = new();
public CommandBarKeyboardPage()
{
InitializeComponent();
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
private void OnLoaded(object? sender, RoutedEventArgs e)
{
DemoBar.Opened += OnOpened;
DemoBar.Closed += OnClosed;
BtnCopy.GotFocus += OnItemFocused;
BtnPaste.GotFocus += OnItemFocused;
BtnBold.GotFocus += OnItemFocused;
BtnShare.GotFocus += OnItemFocused;
BtnDelete.GotFocus += OnItemFocused;
BtnExport.GotFocus += OnItemFocused;
}
private void OnUnloaded(object? sender, RoutedEventArgs e)
{
DemoBar.Opened -= OnOpened;
DemoBar.Closed -= OnClosed;
BtnCopy.GotFocus -= OnItemFocused;
BtnPaste.GotFocus -= OnItemFocused;
BtnBold.GotFocus -= OnItemFocused;
BtnShare.GotFocus -= OnItemFocused;
BtnDelete.GotFocus -= OnItemFocused;
BtnExport.GotFocus -= OnItemFocused;
}
private void OnOpened(object? sender, RoutedEventArgs e)
=> AppendLog("Opened. Use arrow keys to navigate.");
private void OnClosed(object? sender, RoutedEventArgs e)
=> AppendLog("Closed");
private void OnItemFocused(object? sender, FocusChangedEventArgs e)
{
var label = sender switch
{
CommandBarButton btn => btn.Label ?? "(unnamed)",
CommandBarToggleButton t => t.Label ?? "(unnamed)",
_ => sender?.GetType().Name ?? "?"
};
var method = e.NavigationMethod switch
{
NavigationMethod.Directional => "arrow key",
NavigationMethod.Tab => "Tab",
NavigationMethod.Pointer => "pointer",
_ => "unspecified"
};
AppendLog($"Focus: {label} ({method})");
}
private void OnOpenOverflow(object? sender, RoutedEventArgs e)
{
DemoBar.IsOpen = true;
}
private void OnClearLog(object? sender, RoutedEventArgs e)
{
_log.Clear();
FocusLogText.Text = "Log cleared.";
}
private void AppendLog(string message)
{
_log.Add(message);
if (_log.Count > 10)
_log.RemoveAt(0);
FocusLogText.Text = string.Join("\n", _log.Select((entry, i) => $"{i + 1,2}. {entry}"));
}
}
}

1
samples/ControlCatalog/Pages/CommandBarPage.xaml.cs

@ -23,6 +23,7 @@ namespace ControlCatalog.Pages
("Features", "Overflow Menu", "Secondary commands appear in an overflow popup. Configure visibility and sticky behavior.", () => new CommandBarOverflowPage()),
("Features", "Dynamic Overflow", "IsDynamicOverflowEnabled moves primary commands to overflow as space shrinks.", () => new CommandBarDynamicOverflowPage()),
("Features", "Events & State", "Observe Opening, Opened, Closing, and Closed while tracking IsOpen, HasSecondaryCommands, and IsOverflowButtonVisible.", () => new CommandBarEventsPage()),
("Features", "Keyboard Navigation", "Up/Down to move between overflow items, Home/End to jump to first/last, Escape to close and return focus.", () => new CommandBarKeyboardPage()),
};
public CommandBarPage()

128
src/Avalonia.Controls/CommandBar/CommandBar.cs

@ -7,6 +7,7 @@ using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Metadata;
using Avalonia.Threading;
namespace Avalonia.Controls
{
@ -14,9 +15,10 @@ namespace Avalonia.Controls
/// A command bar that provides primary commands displayed inline and secondary commands
/// accessible via an overflow menu.
/// </summary>
[TemplatePart("PART_OverflowButton", typeof(Button))]
[TemplatePart("PART_OverflowButton", typeof(Button))]
[TemplatePart("PART_OverflowPopup", typeof(Popup))]
[TemplatePart("PART_ContentPresenter", typeof(Control))]
[TemplatePart("PART_OverflowPresenter", typeof(ItemsControl))]
[TemplatePart("PART_ContentPresenter", typeof(Control))]
public class CommandBar : TemplatedControl
{
/// <summary>
@ -129,12 +131,14 @@ namespace Avalonia.Controls
private Button? _overflowButton;
private Popup? _overflowPopup;
private ItemsControl? _overflowPresenter;
private Control? _contentPresenter;
private readonly ObservableCollection<ICommandBarElement> _visiblePrimaryCommands = new();
private readonly ObservableCollection<ICommandBarElement> _overflowItems = new();
private bool _isDynamicUpdateInProgress;
private double _constraintWidth = double.PositiveInfinity;
private bool _openedViaKeyboard;
public CommandBar()
{
@ -348,14 +352,33 @@ namespace Avalonia.Controls
base.OnApplyTemplate(e);
if (_overflowButton != null)
{
_overflowButton.Click -= OnOverflowButtonClick;
_overflowButton.GotFocus -= OnOverflowButtonGotFocus;
_overflowButton.RemoveHandler(Input.InputElement.KeyDownEvent, OnOverflowButtonKeyDown);
_overflowButton.RemoveHandler(Input.InputElement.PointerPressedEvent, OnOverflowButtonPointerPressed);
}
if (_overflowPresenter != null)
_overflowPresenter.KeyDown -= OnOverflowPresenterKeyDown;
if (_overflowPopup != null)
_overflowPopup.Opened -= OnOverflowPopupOpened;
_overflowButton = e.NameScope.Find<Button>("PART_OverflowButton");
_overflowPopup = e.NameScope.Find<Popup>("PART_OverflowPopup");
_overflowPresenter = e.NameScope.Find<ItemsControl>("PART_OverflowPresenter");
_contentPresenter = e.NameScope.Find<Control>("PART_ContentPresenter");
if (_overflowButton != null)
{
_overflowButton.Click += OnOverflowButtonClick;
_overflowButton.GotFocus += OnOverflowButtonGotFocus;
_overflowButton.AddHandler(Input.InputElement.KeyDownEvent, OnOverflowButtonKeyDown, handledEventsToo: true);
_overflowButton.AddHandler(Input.InputElement.PointerPressedEvent, OnOverflowButtonPointerPressed, handledEventsToo: true);
}
if (_overflowPresenter != null)
_overflowPresenter.KeyDown += OnOverflowPresenterKeyDown;
if (_overflowPopup != null)
_overflowPopup.Opened += OnOverflowPopupOpened;
ApplyLabelPositionToChildren();
UpdateOverflowButtonVisibility();
@ -369,7 +392,7 @@ namespace Avalonia.Controls
if (change.Property == IsOpenProperty)
{
var isOpen = (bool)change.NewValue!;
var isOpen = change.GetNewValue<bool>();
if (isOpen)
{
RaiseEvent(new RoutedEventArgs(OpeningEvent));
@ -446,6 +469,105 @@ namespace Avalonia.Controls
SetCurrentValue(IsOpenProperty, !IsOpen);
}
private void OnOverflowButtonGotFocus(object? sender, Input.FocusChangedEventArgs e)
{
_openedViaKeyboard = e.NavigationMethod is Input.NavigationMethod.Directional
or Input.NavigationMethod.Tab;
}
private void OnOverflowButtonKeyDown(object? sender, Input.KeyEventArgs e)
{
if (e.Key is Input.Key.Enter or Input.Key.Space)
_openedViaKeyboard = true;
}
private void OnOverflowButtonPointerPressed(object? sender, Input.PointerPressedEventArgs e)
{
_openedViaKeyboard = false;
}
private void OnOverflowPresenterKeyDown(object? sender, Input.KeyEventArgs e)
{
switch (e.Key)
{
case Input.Key.Up:
NavigateOverflow(forward: false);
e.Handled = true;
break;
case Input.Key.Down:
NavigateOverflow(forward: true);
e.Handled = true;
break;
case Input.Key.Home:
FocusOverflowItem(first: true);
e.Handled = true;
break;
case Input.Key.End:
FocusOverflowItem(first: false);
e.Handled = true;
break;
case Input.Key.Escape:
SetCurrentValue(IsOpenProperty, false);
_overflowButton?.Focus(Input.NavigationMethod.Unspecified);
e.Handled = true;
break;
}
}
private void OnOverflowPopupOpened(object? sender, EventArgs e)
{
var method = _openedViaKeyboard
? Input.NavigationMethod.Directional
: Input.NavigationMethod.Pointer;
Dispatcher.UIThread.Post(() =>
{
if (IsOpen)
FocusOverflowItem(first: true, method);
}, DispatcherPriority.Loaded);
}
private void NavigateOverflow(bool forward)
{
var items = GetFocusableOverflowItems();
if (items.Count == 0)
return;
int current = -1;
for (int i = 0; i < items.Count; i++)
{
if (items[i].IsFocused || items[i].IsKeyboardFocusWithin)
{
current = i;
break;
}
}
int next = current < 0
? (forward ? 0 : items.Count - 1)
: (forward ? (current + 1) % items.Count : (current - 1 + items.Count) % items.Count);
items[next].Focus(Input.NavigationMethod.Directional);
}
private void FocusOverflowItem(bool first, Input.NavigationMethod method = Input.NavigationMethod.Directional)
{
var items = GetFocusableOverflowItems();
if (items.Count > 0)
items[first ? 0 : items.Count - 1].Focus(method);
}
private List<Control> GetFocusableOverflowItems()
{
var result = new List<Control>();
foreach (var item in _overflowItems)
{
if (item is Control { IsEnabled: true, IsVisible: true, Focusable: true } control && item is not CommandBarSeparator)
result.Add(control);
}
return result;
}
private void OnPrimaryCommandsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)

5
src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml

@ -237,7 +237,7 @@
Padding="{TemplateBinding Padding}"
MinHeight="{TemplateBinding MinHeight}">
<Panel>
<DockPanel>
<DockPanel KeyboardNavigation.TabNavigation="Cycle">
<!-- Overflow button (docked right) -->
<Button x:Name="PART_OverflowButton"
DockPanel.Dock="Right"
@ -286,7 +286,8 @@
CornerRadius="{DynamicResource OverlayCornerRadius}"
Padding="4"
MinWidth="160">
<ItemsControl ItemsSource="{Binding OverflowItems, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl x:Name="PART_OverflowPresenter"
ItemsSource="{Binding OverflowItems, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="2" />

5
src/Avalonia.Themes.Simple/Controls/CommandBar.xaml

@ -234,7 +234,7 @@
Padding="{TemplateBinding Padding}"
MinHeight="{TemplateBinding MinHeight}">
<Panel>
<DockPanel>
<DockPanel KeyboardNavigation.TabNavigation="Cycle">
<!-- Overflow button (docked right) -->
<Button x:Name="PART_OverflowButton"
DockPanel.Dock="Right"
@ -281,7 +281,8 @@
BorderThickness="{DynamicResource ThemeBorderThickness}"
Padding="4"
MinWidth="160">
<ItemsControl ItemsSource="{Binding OverflowItems, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl x:Name="PART_OverflowPresenter"
ItemsSource="{Binding OverflowItems, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="2" />

223
tests/Avalonia.Controls.UnitTests/CommandBarTests.cs

@ -1,7 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Xunit;
namespace Avalonia.Controls.UnitTests;
@ -973,6 +980,222 @@ public class CommandBarItemWidthTests : ScopedTestBase
}
}
public class CommandBarOverflowKeyboardTests : ScopedTestBase
{
private readonly IDisposable _app;
public CommandBarOverflowKeyboardTests()
{
_app = UnitTestApplication.Start(TestServices.FocusableWindow);
}
public override void Dispose()
{
_app.Dispose();
base.Dispose();
}
[Fact]
public void Escape_WhenOverflowOpen_ClosesOverflow()
{
var cb = new CommandBar();
cb.SecondaryCommands.Add(new CommandBarButton { Label = "Action" });
var root = new TestRoot(useGlobalStyles: true, child: cb);
root.LayoutManager.ExecuteInitialLayoutPass();
cb.IsOpen = true;
RaiseKeyOnOverflowPresenter(cb, Key.Escape);
Assert.False(cb.IsOpen);
}
[Fact]
public void Escape_WhenOverflowClosed_DoesNothing()
{
var cb = new CommandBar();
cb.SecondaryCommands.Add(new CommandBarButton { Label = "Action" });
var root = new TestRoot(useGlobalStyles: true, child: cb);
root.LayoutManager.ExecuteInitialLayoutPass();
RaiseKeyOnOverflowPresenter(cb, Key.Escape);
Assert.False(cb.IsOpen);
}
[Theory]
[InlineData(Key.Down)]
[InlineData(Key.Up)]
[InlineData(Key.Home)]
[InlineData(Key.End)]
public void NavigationKeys_AreHandled_WhenOverflowHasItems(Key key)
{
var cb = new CommandBar();
cb.SecondaryCommands.Add(new CommandBarButton { Label = "A" });
cb.SecondaryCommands.Add(new CommandBarButton { Label = "B" });
var root = new TestRoot(useGlobalStyles: true, child: cb);
root.LayoutManager.ExecuteInitialLayoutPass();
cb.IsOpen = true;
var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key };
GetOverflowPresenter(cb).RaiseEvent(e);
Assert.True(e.Handled);
}
[Fact]
public void NavigationKeys_AreHandled_WhenAllItemsAreSeparators()
{
var cb = new CommandBar();
cb.SecondaryCommands.Add(new CommandBarSeparator());
var root = new TestRoot(useGlobalStyles: true, child: cb);
root.LayoutManager.ExecuteInitialLayoutPass();
cb.IsOpen = true;
var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down };
GetOverflowPresenter(cb).RaiseEvent(e);
Assert.True(e.Handled);
}
[Fact]
public void NavigationKeys_AreHandled_WhenAllItemsAreDisabled()
{
var cb = new CommandBar();
cb.SecondaryCommands.Add(new CommandBarButton { Label = "A", IsEnabled = false });
var root = new TestRoot(useGlobalStyles: true, child: cb);
root.LayoutManager.ExecuteInitialLayoutPass();
cb.IsOpen = true;
var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down };
GetOverflowPresenter(cb).RaiseEvent(e);
Assert.True(e.Handled);
}
[Fact]
public void NavigationKeys_AreHandled_WhenAllItemsAreNonFocusable()
{
var cb = new CommandBar();
cb.SecondaryCommands.Add(new CommandBarButton { Label = "A", Focusable = false });
var root = new TestRoot(useGlobalStyles: true, child: cb);
root.LayoutManager.ExecuteInitialLayoutPass();
cb.IsOpen = true;
var e = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down };
GetOverflowPresenter(cb).RaiseEvent(e);
Assert.True(e.Handled);
}
[Fact]
public void KeyboardOpen_FocusesFirstOverflowItem_AsFocusVisible()
{
var first = new CommandBarButton { Label = "A" };
var cb = new CommandBar();
cb.SecondaryCommands.Add(first);
var window = CreateWindow(cb);
Assert.True(GetOverflowButton(cb).Focus(NavigationMethod.Tab));
cb.IsOpen = true;
Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded, TestContext.Current.CancellationToken);
Assert.True(first.IsFocused);
Assert.True(first.Classes.Contains(":focus-visible"));
}
[Fact]
public void PointerOpen_AfterKeyboardFocus_DoesNotMakeFirstOverflowItemFocusVisible()
{
var first = new CommandBarButton { Label = "A" };
var cb = new CommandBar();
cb.SecondaryCommands.Add(first);
var window = CreateWindow(cb);
var overflowButton = GetOverflowButton(cb);
Assert.True(overflowButton.Focus(NavigationMethod.Tab));
RaisePointerPressed(overflowButton);
cb.IsOpen = true;
Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded, TestContext.Current.CancellationToken);
Assert.True(first.IsFocused);
Assert.False(first.Classes.Contains(":focus-visible"));
}
[Fact]
public void KeyboardOpen_AfterPointerFocus_MakesFirstOverflowItemFocusVisible()
{
var first = new CommandBarButton { Label = "A" };
var cb = new CommandBar();
cb.SecondaryCommands.Add(first);
var window = CreateWindow(cb);
var overflowButton = GetOverflowButton(cb);
Assert.True(overflowButton.Focus(NavigationMethod.Pointer));
overflowButton.RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = Key.Space,
});
cb.IsOpen = true;
Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded, TestContext.Current.CancellationToken);
Assert.True(first.IsFocused);
Assert.True(first.Classes.Contains(":focus-visible"));
}
private static ItemsControl GetOverflowPresenter(CommandBar cb)
{
var popup = cb.GetVisualDescendants()
.OfType<Popup>()
.First(p => p.Name == "PART_OverflowPopup");
return popup.GetLogicalDescendants()
.OfType<ItemsControl>()
.First(x => x.Name == "PART_OverflowPresenter");
}
private static void RaiseKeyOnOverflowPresenter(CommandBar cb, Key key)
{
GetOverflowPresenter(cb).RaiseEvent(new KeyEventArgs
{
RoutedEvent = InputElement.KeyDownEvent,
Key = key,
});
}
private static Button GetOverflowButton(CommandBar cb)
{
return cb.GetVisualDescendants()
.OfType<Button>()
.First(x => x.Name == "PART_OverflowButton");
}
private static Window CreateWindow(Control content)
{
var window = new Window { Content = content };
window.Show();
window.ApplyStyling();
window.ApplyTemplate();
return window;
}
private static void RaisePointerPressed(Button target)
{
var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true);
target.RaiseEvent(new PointerPressedEventArgs(
target,
pointer,
target,
default,
timestamp: 1,
new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
KeyModifiers.None));
}
}
file sealed class DelegateCommand : System.Windows.Input.ICommand
{
private readonly System.Action<object?> _execute;

Loading…
Cancel
Save