From 43e80392df1100f6b1ec9120a851db09832f8540 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 9 Jun 2015 18:14:17 +0200 Subject: [PATCH] Started adding keyboard accessibility to Menu. Still quite a lot to do. --- Perspex.Controls/Menu.cs | 62 ++++++++++---- Perspex.Controls/MenuItem.cs | 85 ++++++++++++++++--- Perspex.Controls/Popup.cs | 4 + Perspex.Controls/TopLevel.cs | 12 ++- Perspex.Controls/Window.cs | 3 +- Perspex.Input/AccessKeyHandler.cs | 24 +++++- Perspex.Input/FocusManager.cs | 23 ++++- Perspex.Input/IMainMenu.cs | 4 + Perspex.Themes.Default/MenuItemStyle.cs | 12 +-- .../Primitives/SelectingItemsControlTests.cs | 24 +++++- 10 files changed, 206 insertions(+), 47 deletions(-) diff --git a/Perspex.Controls/Menu.cs b/Perspex.Controls/Menu.cs index 5eeb375525..f831a70f3e 100644 --- a/Perspex.Controls/Menu.cs +++ b/Perspex.Controls/Menu.cs @@ -13,11 +13,12 @@ namespace Perspex.Controls using Perspex.LogicalTree; using Perspex.Rendering; using Perspex.Interactivity; + using Perspex.Controls.Primitives; /// /// A top-level menu control. /// - public class Menu : ItemsControl, IFocusScope, IMainMenu + public class Menu : SelectingItemsControl, IFocusScope, IMainMenu { /// /// Defines the default items panel used by a . @@ -55,6 +56,29 @@ namespace Perspex.Controls private set { this.SetValue(IsOpenProperty, value); } } + /// + /// Closes the menu. + /// + public void CloseMenu() + { + foreach (MenuItem i in this.GetLogicalChildren()) + { + i.IsSubMenuOpen = false; + } + + this.IsOpen = false; + this.SelectedIndex = -1; + } + + /// + /// Opens the menu in response to the Alt/F10 key. + /// + public void OpenMenu() + { + this.SelectedIndex = 0; + ((IInputElement)this.SelectedItem)?.Focus(); + } + /// /// Called when the is attached to the visual tree. /// @@ -75,6 +99,13 @@ namespace Perspex.Controls this.subscription = new CompositeDisposable( pointerPress, Disposable.Create(() => topLevel.Deactivated -= this.Deactivated)); + + var inputRoot = root as IInputRoot; + + if (inputRoot != null && inputRoot.AccessKeyHandler != null) + { + inputRoot.AccessKeyHandler.MainMenu = this; + } } /// @@ -87,6 +118,20 @@ namespace Perspex.Controls this.subscription.Dispose(); } + /// + /// Called when a key is pressed within the menu. + /// + /// The event args. + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (this.IsOpen && e.Key == Key.Escape) + { + this.CloseMenu(); + } + } + /// /// Called when a submenu opens somewhere in the menu. /// @@ -109,19 +154,6 @@ namespace Perspex.Controls this.IsOpen = true; } - /// - /// Closes the menu. - /// - private void CloseMenu() - { - foreach (MenuItem i in this.GetLogicalChildren()) - { - i.IsSubMenuOpen = false; - } - - this.IsOpen = false; - } - /// /// Called when the top-level window is deactivated. /// @@ -129,7 +161,7 @@ namespace Perspex.Controls /// The event args. private void Deactivated(object sender, EventArgs e) { - this.CloseMenu(); + //this.CloseMenu(); } /// diff --git a/Perspex.Controls/MenuItem.cs b/Perspex.Controls/MenuItem.cs index 616d52b71e..069ca74cbe 100644 --- a/Perspex.Controls/MenuItem.cs +++ b/Perspex.Controls/MenuItem.cs @@ -15,11 +15,13 @@ namespace Perspex.Controls using Perspex.Rendering; using Perspex.Controls.Templates; using Perspex.Controls.Presenters; + using Perspex.VisualTree; + /// /// A menu item control. /// - public class MenuItem : HeaderedItemsControl + public class MenuItem : SelectingItemsControl, ISelectable { /// /// Defines the property. @@ -33,12 +35,24 @@ namespace Perspex.Controls public static readonly PerspexProperty CommandParameterProperty = Button.CommandParameterProperty.AddOwner(); + /// + /// Defines the property. + /// + public static readonly PerspexProperty HeaderProperty = + HeaderedItemsControl.HeaderProperty.AddOwner(); + /// /// Defines the property. /// public static readonly PerspexProperty IconProperty = PerspexProperty.Register(nameof(Icon)); + /// + /// Defines the property. + /// + public static readonly PerspexProperty IsSelectedProperty = + ListBoxItem.IsSelectedProperty.AddOwner(); + /// /// Defines the property. /// @@ -109,6 +123,15 @@ namespace Perspex.Controls set { this.SetValue(CommandParameterProperty, value); } } + /// + /// Gets or sets the 's header. + /// + public object Header + { + get { return this.GetValue(HeaderProperty); } + set { this.SetValue(HeaderProperty, value); } + } + /// /// Gets or sets the icon that appears in a . /// @@ -118,6 +141,15 @@ namespace Perspex.Controls set { this.SetValue(IconProperty, value); } } + /// + /// Gets or sets a value indicating whether the is currently selected. + /// + public bool IsSelected + { + get { return this.GetValue(IsSelectedProperty); } + set { this.SetValue(IsSelectedProperty, value); } + } + /// /// Gets or sets a value that indicates whether the submenu of the is /// open. @@ -167,6 +199,20 @@ namespace Perspex.Controls } } + /// + /// Called when a key is pressed in the . + /// + /// The event args. + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (this.IsTopLevel && e.Key == Key.Down && this.HasSubMenu) + { + this.IsSubMenuOpen = true; + } + } + /// /// Called when the pointer enters the . /// @@ -253,7 +299,8 @@ namespace Perspex.Controls if (popup != null) { - popup.Opened += this.PopupFirstOpened; + popup.PopupRootCreated += this.PopupRootCreated; + popup.Opened += this.PopupOpened; } } @@ -286,38 +333,48 @@ namespace Perspex.Controls else { sender.CloseSubmenus(); + sender.SelectedIndex = -1; } } } + private void PopupOpened(object sender, EventArgs e) + { + this.SelectedIndex = 0; + } + /// - /// Called the first time the MenuItem's popup is opened. + /// Called when the MenuItem's popup root is opened. /// /// The event sender. /// The event args. - private void PopupFirstOpened(object sender, EventArgs e) + private void PopupRootCreated(object sender, EventArgs e) { var popup = (Popup)sender; + ItemsPresenter presenter = null; // Our ItemsPresenter is in a Popup which means that it's only created when the // Popup is opened, therefore it wasn't found by ItemsControl.OnTemplateApplied. // Now the Popup has been opened for the first time it should exist, so make sure // the PopupRoot's template is applied and look for the ItemsPresenter. - popup.PopupRoot.ApplyTemplate(); - var presenter = popup.PopupRoot.FindControl("itemsPresenter"); + foreach (var c in popup.PopupRoot.GetSelfAndVisualDescendents().OfType()) + { + if (c.Name == "itemsPresenter" && c is ItemsPresenter) + { + presenter = c as ItemsPresenter; + break; + } + + c.ApplyTemplate(); + } if (presenter != null) { - // The presenter was found. First make its Panel's ChildLogicalParent point to - // this so that the child MenuItems will be logically parented by the parent - // MenuItem and then assign it to our Presenter property. - presenter.ApplyTemplate(); - ((IItemsPanel)presenter.Panel).ChildLogicalParent = this; + // The presenter was found. Set its TemplatedParent so it thinks that it had a + // normal birth; may it never know its own perveristy. + presenter.TemplatedParent = this; this.Presenter = presenter; } - - // Don't call this event handler again. - popup.Opened -= this.PopupFirstOpened; } } } diff --git a/Perspex.Controls/Popup.cs b/Perspex.Controls/Popup.cs index eb9670cc46..d7aedb3a89 100644 --- a/Perspex.Controls/Popup.cs +++ b/Perspex.Controls/Popup.cs @@ -49,6 +49,8 @@ namespace Perspex.Controls public event EventHandler Opened; + public event EventHandler PopupRootCreated; + public Control Child { get { return this.GetValue(ChildProperty); } @@ -116,6 +118,8 @@ namespace Perspex.Controls this.popupRoot.AddHandler(PopupRoot.PointerPressedEvent, this.MaybeClose, RoutingStrategies.Bubble, true); this.topLevel.AddHandler(TopLevel.PointerPressedEvent, this.MaybeClose, RoutingStrategies.Tunnel); + this.PopupRootCreated?.Invoke(this, EventArgs.Empty); + this.popupRoot.Show(); this.IsOpen = true; this.Opened?.Invoke(this, EventArgs.Empty); diff --git a/Perspex.Controls/TopLevel.cs b/Perspex.Controls/TopLevel.cs index 2c77823684..7d96da1f2b 100644 --- a/Perspex.Controls/TopLevel.cs +++ b/Perspex.Controls/TopLevel.cs @@ -28,7 +28,7 @@ namespace Perspex.Controls /// . It handles scheduling layout, styling and rendering as well as /// tracking the window and state. /// - public abstract class TopLevel : ContentControl, IInputRoot, ILayoutRoot, IRenderRoot, ICloseable, IFocusScope + public abstract class TopLevel : ContentControl, IInputRoot, ILayoutRoot, IRenderRoot, ICloseable { /// /// Defines the property. @@ -224,7 +224,7 @@ namespace Perspex.Controls /// IAccessKeyHandler IInputRoot.AccessKeyHandler { - get; + get { return this.accessKeyHandler; } } /// @@ -294,7 +294,13 @@ namespace Perspex.Controls this.Activated(this, EventArgs.Empty); } - FocusManager.Instance.SetFocusScope(this); + var scope = this as IFocusScope; + + if (scope != null) + { + FocusManager.Instance.SetFocusScope(scope); + } + this.IsActive = true; } diff --git a/Perspex.Controls/Window.cs b/Perspex.Controls/Window.cs index 07e0b2a74f..fc577d49cb 100644 --- a/Perspex.Controls/Window.cs +++ b/Perspex.Controls/Window.cs @@ -9,12 +9,13 @@ namespace Perspex.Controls using System; using System.Reactive.Linq; using System.Threading.Tasks; + using Perspex.Input; using Perspex.Media; using Perspex.Platform; using Perspex.Styling; using Splat; - public class Window : TopLevel, IStyleable + public class Window : TopLevel, IStyleable, IFocusScope { public static readonly PerspexProperty TitleProperty = PerspexProperty.Register("Title", "Window"); diff --git a/Perspex.Input/AccessKeyHandler.cs b/Perspex.Input/AccessKeyHandler.cs index b8f290cac9..42747692bd 100644 --- a/Perspex.Input/AccessKeyHandler.cs +++ b/Perspex.Input/AccessKeyHandler.cs @@ -45,16 +45,17 @@ namespace Perspex.Input this.owner = owner; - this.owner.AddHandler(InputElement.KeyDownEvent, this.OnKeyDown); + this.owner.AddHandler(InputElement.KeyDownEvent, this.OnPreviewKeyDown, RoutingStrategies.Tunnel); + this.owner.AddHandler(InputElement.KeyUpEvent, this.OnPreviewKeyUp, RoutingStrategies.Tunnel); this.owner.AddHandler(InputElement.PointerPressedEvent, this.OnPreviewPointerPressed, RoutingStrategies.Tunnel); } /// - /// Handles Alt and F10 key presses in the window. + /// Handles the Alt/F10 keys being pressed in the window. /// /// The event sender. /// The event args. - protected virtual void OnKeyDown(object sender, KeyEventArgs e) + protected virtual void OnPreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.LeftAlt || e.Key == Key.F10) { @@ -63,6 +64,23 @@ namespace Perspex.Input } } + /// + /// Handles the Alt/F10 keys being released in the window. + /// + /// The event sender. + /// The event args. + protected virtual void OnPreviewKeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.LeftAlt || e.Key == Key.F10) + { + if (this.showingAccessKeys && this.MainMenu != null) + { + this.MainMenu.OpenMenu(); + e.Handled = true; + } + } + } + /// /// Handles pointer presses in the window. /// diff --git a/Perspex.Input/FocusManager.cs b/Perspex.Input/FocusManager.cs index 2536345232..35703a71ee 100644 --- a/Perspex.Input/FocusManager.cs +++ b/Perspex.Input/FocusManager.cs @@ -9,25 +9,39 @@ namespace Perspex.Input using System; using System.Collections.Generic; using System.Linq; - using Perspex.Interactivity; using Perspex.VisualTree; using Splat; + /// + /// Manages focus for the application. + /// public class FocusManager : IFocusManager { - private Dictionary focusScopes = + /// + /// The focus scopes in which the focus is currently defined. + /// + private Dictionary focusScopes = new Dictionary(); + /// + /// Gets the instance of the . + /// public static IFocusManager Instance { get { return Locator.Current.GetService(); } } + /// + /// Gets the currently focused . + /// public IInputElement Current { get { return KeyboardDevice.Instance.FocusedElement; } } + /// + /// Gets the current focus scope. + /// public IFocusScope Scope { get; @@ -51,6 +65,7 @@ namespace Perspex.Input if (scope != null) { + this.Scope = scope; this.SetFocusedElement(scope, control, keyboardNavigated); } } @@ -73,8 +88,8 @@ namespace Perspex.Input /// will change. /// public void SetFocusedElement( - IFocusScope scope, - IInputElement element, + IFocusScope scope, + IInputElement element, bool keyboardNavigated = false) { Contract.Requires(scope != null); diff --git a/Perspex.Input/IMainMenu.cs b/Perspex.Input/IMainMenu.cs index 063037bab8..2ebea79223 100644 --- a/Perspex.Input/IMainMenu.cs +++ b/Perspex.Input/IMainMenu.cs @@ -11,5 +11,9 @@ namespace Perspex.Input /// public interface IMainMenu { + /// + /// Opens the menu in response to the Alt/F10 key. + /// + void OpenMenu(); } } diff --git a/Perspex.Themes.Default/MenuItemStyle.cs b/Perspex.Themes.Default/MenuItemStyle.cs index 4733b60987..16cbb1688e 100644 --- a/Perspex.Themes.Default/MenuItemStyle.cs +++ b/Perspex.Themes.Default/MenuItemStyle.cs @@ -41,7 +41,7 @@ namespace Perspex.Themes.Default new Setter(MenuItem.TemplateProperty, ControlTemplate.Create(this.TopLevelTemplate)), }, }, - new Style(x => x.OfType().Class(":pointerover").Template().Name("root")) + new Style(x => x.OfType().Class(":selected").Template().Name("root")) { Setters = new[] { @@ -49,19 +49,19 @@ namespace Perspex.Themes.Default new Setter(Border.BorderBrushProperty, new SolidColorBrush(0xff26a0da)), }, }, - new Style(x => x.OfType().Class(":empty").Template().Name("rightArrow")) + new Style(x => x.OfType().Class(":pointerover").Template().Name("root")) { Setters = new[] { - new Setter(Path.IsVisibleProperty, false), + new Setter(Border.BackgroundProperty, new SolidColorBrush(0x3d26a0da)), + new Setter(Border.BorderBrushProperty, new SolidColorBrush(0xff26a0da)), }, }, - new Style(x => x.OfType().Child().OfType().PropertyEquals(MenuItem.IsSubMenuOpenProperty, true).Template().Name("root")) + new Style(x => x.OfType().Class(":empty").Template().Name("rightArrow")) { Setters = new[] { - new Setter(Border.BackgroundProperty, new SolidColorBrush(0x3d26a0da)), - new Setter(Border.BorderBrushProperty, new SolidColorBrush(0xff26a0da)), + new Setter(Path.IsVisibleProperty, false), }, }, }); diff --git a/Tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/Tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index c9302205c7..9a469f1060 100644 --- a/Tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/Tests/Perspex.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -97,6 +97,28 @@ namespace Perspex.Controls.Primitives.UnitTests Assert.True(items[1].IsSelected); } + [Fact] + public void Setting_SelectedIndex_Before_ApplyTemplate_Should_Set_Item_IsSelected_True() + { + var items = new[] + { + new Item(), + new Item(), + }; + + var target = new Target + { + Items = items, + Template = this.Template(), + }; + + target.SelectedIndex = 1; + target.ApplyTemplate(); + + Assert.False(items[0].IsSelected); + Assert.True(items[1].IsSelected); + } + [Fact] public void Setting_SelectedItem_Should_Set_SelectedIndex() { @@ -175,7 +197,7 @@ namespace Perspex.Controls.Primitives.UnitTests new Item(), new Item(), }; - + var target = new Target { Items = items,