Browse Source

Merge pull request #4974 from AvaloniaUI/feature/keyboard-focus-within

Implement IsKeyboardFocusWithin
Max Katz 5 years ago
parent
commit
bb89d8d480

+ 4 - 0
src/Avalonia.Input/ApiCompatBaseline.txt

@@ -0,0 +1,4 @@
+Compat issues with assembly Avalonia.Input:
+InterfacesShouldHaveSameMembers : Interface member 'public System.Boolean Avalonia.Input.IInputElement.IsKeyboardFocusWithin' is present in the implementation but not in the contract.
+InterfacesShouldHaveSameMembers : Interface member 'public System.Boolean Avalonia.Input.IInputElement.IsKeyboardFocusWithin.get()' is present in the implementation but not in the contract.
+Total Issues: 2

+ 5 - 0
src/Avalonia.Input/IInputElement.cs

@@ -89,6 +89,11 @@ namespace Avalonia.Input
         /// <see cref="IsEnabled"/> value of this control and its parent controls.
         /// </remarks>
         bool IsEffectivelyEnabled { get; }
+        
+        /// <summary>
+        /// Gets a value indicating whether keyboard focus is anywhere within the element or its visual tree child elements.
+        /// </summary>
+        bool IsKeyboardFocusWithin { get; }
 
         /// <summary>
         /// Gets a value indicating whether the control is focused.

+ 22 - 0
src/Avalonia.Input/InputElement.cs

@@ -42,6 +42,14 @@ namespace Avalonia.Input
         public static readonly StyledProperty<Cursor?> CursorProperty =
             AvaloniaProperty.Register<InputElement, Cursor?>(nameof(Cursor), null, true);
 
+        /// <summary>
+        /// Defines the <see cref="IsKeyboardFocusWithin"/> property.
+        /// </summary>
+        public static readonly DirectProperty<InputElement, bool> IsKeyboardFocusWithinProperty =
+            AvaloniaProperty.RegisterDirect<InputElement, bool>(
+                nameof(IsKeyboardFocusWithin),
+                o => o.IsKeyboardFocusWithin);
+        
         /// <summary>
         /// Defines the <see cref="IsFocused"/> property.
         /// </summary>
@@ -160,6 +168,7 @@ namespace Avalonia.Input
 
         private bool _isEffectivelyEnabled = true;
         private bool _isFocused;
+        private bool _isKeyboardFocusWithin;
         private bool _isFocusVisible;
         private bool _isPointerOver;
         private GestureRecognizerCollection? _gestureRecognizers;
@@ -343,6 +352,15 @@ namespace Avalonia.Input
             get { return GetValue(CursorProperty); }
             set { SetValue(CursorProperty, value); }
         }
+        
+        /// <summary>
+        /// Gets a value indicating whether keyboard focus is anywhere within the element or its visual tree child elements.
+        /// </summary>
+        public bool IsKeyboardFocusWithin
+        {
+            get => _isKeyboardFocusWithin;
+            internal set => SetAndRaise(IsKeyboardFocusWithinProperty, ref _isKeyboardFocusWithin, value); 
+        }
 
         /// <summary>
         /// Gets a value indicating whether the control is focused.
@@ -544,6 +562,10 @@ namespace Avalonia.Input
             {
                 UpdatePseudoClasses(null, change.NewValue.GetValueOrDefault<bool>());
             }
+            else if (change.Property == IsKeyboardFocusWithinProperty)
+            {
+                PseudoClasses.Set(":focus-within", _isKeyboardFocusWithin);
+            }
         }
 
         /// <summary>

+ 117 - 0
src/Avalonia.Input/KeyboardDevice.cs

@@ -9,6 +9,7 @@ namespace Avalonia.Input
     public class KeyboardDevice : IKeyboardDevice, INotifyPropertyChanged
     {
         private IInputElement? _focusedElement;
+        private IInputRoot? _focusedRoot;
 
         public event PropertyChangedEventHandler? PropertyChanged;
 
@@ -28,10 +29,115 @@ namespace Avalonia.Input
             private set
             {
                 _focusedElement = value;
+
+                if (_focusedElement != null && _focusedElement.IsAttachedToVisualTree)
+                {
+                    _focusedRoot = _focusedElement.VisualRoot as IInputRoot;
+                }
+                else
+                {
+                    _focusedRoot = null;
+                }
+                
                 RaisePropertyChanged();
             }
         }
 
+        private void ClearFocusWithinAncestors(IInputElement? element)
+        {
+            var el = element;
+            
+            while (el != null)
+            {
+                if (el is InputElement ie)
+                {
+                    ie.IsKeyboardFocusWithin = false;
+                }
+
+                el = (IInputElement)el.VisualParent;
+            }
+        }
+        
+        private void ClearFocusWithin(IInputElement element, bool clearRoot)
+        {
+            foreach (var visual in element.VisualChildren)
+            {
+                if (visual is IInputElement el && el.IsKeyboardFocusWithin)
+                {
+                    ClearFocusWithin(el, true);
+                    break;
+                }
+            }
+            
+            if (clearRoot)
+            {
+                if (element is InputElement ie)
+                {
+                    ie.IsKeyboardFocusWithin = false;
+                }
+            }
+        }
+
+        private void SetIsFocusWithin(IInputElement? oldElement, IInputElement? newElement)
+        {
+            if (newElement == null && oldElement != null)
+            {
+                ClearFocusWithinAncestors(oldElement);
+                return;
+            }
+            
+            IInputElement? branch = null;
+
+            var el = newElement;
+
+            while (el != null)
+            {
+                if (el.IsKeyboardFocusWithin)
+                {
+                    branch = el;
+                    break;
+                }
+
+                el = el.VisualParent as IInputElement;
+            }
+
+            el = oldElement;
+
+            if (el != null && branch != null)
+            {
+                ClearFocusWithin(branch, false);
+            }
+
+            el = newElement;
+            
+            while (el != null && el != branch)
+            {
+                if (el is InputElement ie)
+                {
+                    ie.IsKeyboardFocusWithin = true;
+                }
+
+                el = el.VisualParent as IInputElement;
+            }
+        }
+        
+        private void ClearChildrenFocusWithin(IInputElement element, bool clearRoot)
+        {
+            foreach (var visual in element.VisualChildren)
+            {
+                if (visual is IInputElement el && el.IsKeyboardFocusWithin)
+                {
+                    ClearChildrenFocusWithin(el, true);
+                    break;
+                }
+            }
+            
+            if (clearRoot && element is InputElement ie)
+            {
+                ie.IsKeyboardFocusWithin = false;
+            }
+        }
+
         public void SetFocusedElement(
             IInputElement? element, 
             NavigationMethod method,
@@ -40,6 +146,17 @@ namespace Avalonia.Input
             if (element != FocusedElement)
             {
                 var interactive = FocusedElement as IInteractive;
+
+                if (FocusedElement != null && 
+                    (!FocusedElement.IsAttachedToVisualTree ||
+                     _focusedRoot != element?.VisualRoot as IInputRoot) &&
+                    _focusedRoot != null)
+                {
+                    ClearChildrenFocusWithin(_focusedRoot, true);
+                }
+                
+                SetIsFocusWithin(FocusedElement, element);
+                
                 FocusedElement = element;
 
                 interactive?.RaiseEvent(new RoutedEventArgs

+ 197 - 0
tests/Avalonia.Input.UnitTests/InputElement_Focus.cs

@@ -121,5 +121,202 @@ namespace Avalonia.Input.UnitTests
                 Assert.False(target2.Classes.Contains(":focus-visible"));
             }
         }
+        
+        [Fact]
+        public void Control_FocusWithin_PseudoClass_Should_Be_Applied()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var target1 = new Decorator();
+                var target2 = new Decorator();
+                var root = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children =
+                        {
+                            target1,
+                            target2
+                        }
+                    }
+                };
+
+                target1.ApplyTemplate();
+                target2.ApplyTemplate();
+
+                FocusManager.Instance?.Focus(target1);
+                Assert.True(target1.IsFocused);
+                Assert.True(target1.Classes.Contains(":focus-within"));
+                Assert.True(target1.IsKeyboardFocusWithin);
+                Assert.True(root.Child.Classes.Contains(":focus-within"));
+                Assert.True(root.Child.IsKeyboardFocusWithin);
+                Assert.True(root.Classes.Contains(":focus-within"));
+                Assert.True(root.IsKeyboardFocusWithin);
+            }
+        }
+        
+        [Fact]
+        public void Control_FocusWithin_PseudoClass_Should_Be_Applied_and_Removed()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var target1 = new Decorator();
+                var target2 = new Decorator();
+                var panel1 = new Panel { Children = { target1 } };
+                var panel2 = new Panel { Children = { target2 } };
+                var root = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children =
+                        {
+                            panel1,
+                            panel2
+                        }
+                    }
+                };
+                
+                target1.ApplyTemplate();
+                target2.ApplyTemplate();
+
+                FocusManager.Instance?.Focus(target1);
+                Assert.True(target1.IsFocused);
+                Assert.True(target1.Classes.Contains(":focus-within"));
+                Assert.True(target1.IsKeyboardFocusWithin);
+                Assert.True(panel1.Classes.Contains(":focus-within"));
+                Assert.True(panel1.IsKeyboardFocusWithin);
+                Assert.True(root.Child.Classes.Contains(":focus-within"));
+                Assert.True(root.Child.IsKeyboardFocusWithin);
+                Assert.True(root.Classes.Contains(":focus-within"));
+                Assert.True(root.IsKeyboardFocusWithin);
+                
+                FocusManager.Instance?.Focus(target2);
+                
+                Assert.False(target1.IsFocused);
+                Assert.False(target1.Classes.Contains(":focus-within"));
+                Assert.False(target1.IsKeyboardFocusWithin);
+                Assert.False(panel1.Classes.Contains(":focus-within"));
+                Assert.False(panel1.IsKeyboardFocusWithin);
+                Assert.True(root.Child.Classes.Contains(":focus-within"));
+                Assert.True(root.Child.IsKeyboardFocusWithin);
+                Assert.True(root.Classes.Contains(":focus-within"));
+                Assert.True(root.IsKeyboardFocusWithin);
+                
+                Assert.True(target2.IsFocused);
+                Assert.True(target2.Classes.Contains(":focus-within"));
+                Assert.True(target2.IsKeyboardFocusWithin);
+                Assert.True(panel2.Classes.Contains(":focus-within"));
+                Assert.True(panel2.IsKeyboardFocusWithin);
+            }
+        }
+        
+        [Fact]
+        public void Control_FocusWithin_Pseudoclass_Should_Be_Removed_When_Removed_From_Tree()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var target1 = new Decorator();
+                var target2 = new Decorator();
+                var root = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children =
+                        {
+                            target1,
+                            target2
+                        }
+                    }
+                };
+
+                target1.ApplyTemplate();
+                target2.ApplyTemplate();
+
+                FocusManager.Instance?.Focus(target1);
+                Assert.True(target1.IsFocused);
+                Assert.True(target1.Classes.Contains(":focus-within"));
+                Assert.True(target1.IsKeyboardFocusWithin);
+                Assert.True(root.Child.Classes.Contains(":focus-within"));
+                Assert.True(root.Child.IsKeyboardFocusWithin);
+                Assert.True(root.Classes.Contains(":focus-within"));
+                Assert.True(root.IsKeyboardFocusWithin);
+
+                Assert.Equal(KeyboardDevice.Instance.FocusedElement, target1);
+                
+                root.Child = null;
+                
+                Assert.Null(KeyboardDevice.Instance.FocusedElement);
+                
+                Assert.False(target1.IsFocused);
+                Assert.False(target1.Classes.Contains(":focus-within"));
+                Assert.False(target1.IsKeyboardFocusWithin);
+                Assert.False(root.Classes.Contains(":focus-within"));
+                Assert.False(root.IsKeyboardFocusWithin);
+            }
+        }
+        
+        [Fact]
+        public void Control_FocusWithin_Pseudoclass_Should_Be_Removed_Focus_Moves_To_Different_Root()
+        {
+            using (UnitTestApplication.Start(TestServices.RealFocus))
+            {
+                var target1 = new Decorator();
+                var target2 = new Decorator();
+                
+                var root1 = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children =
+                        {
+                            target1,
+                        }
+                    }
+                };
+                
+                var root2 = new TestRoot
+                {
+                    Child = new StackPanel
+                    {
+                        Children =
+                        {
+                            target2,
+                        }
+                    }
+                };
+
+                target1.ApplyTemplate();
+                target2.ApplyTemplate();
+
+                FocusManager.Instance?.Focus(target1);
+                Assert.True(target1.IsFocused);
+                Assert.True(target1.Classes.Contains(":focus-within"));
+                Assert.True(target1.IsKeyboardFocusWithin);
+                Assert.True(root1.Child.Classes.Contains(":focus-within"));
+                Assert.True(root1.Child.IsKeyboardFocusWithin);
+                Assert.True(root1.Classes.Contains(":focus-within"));
+                Assert.True(root1.IsKeyboardFocusWithin);
+
+                Assert.Equal(KeyboardDevice.Instance.FocusedElement, target1);
+                
+                FocusManager.Instance?.Focus(target2);
+                
+                Assert.False(target1.IsFocused);
+                Assert.False(target1.Classes.Contains(":focus-within"));
+                Assert.False(target1.IsKeyboardFocusWithin);
+                Assert.False(root1.Child.Classes.Contains(":focus-within"));
+                Assert.False(root1.Child.IsKeyboardFocusWithin);
+                Assert.False(root1.Classes.Contains(":focus-within"));
+                Assert.False(root1.IsKeyboardFocusWithin);
+                
+                Assert.True(target2.IsFocused);
+                Assert.True(target2.Classes.Contains(":focus-within"));
+                Assert.True(target2.IsKeyboardFocusWithin);
+                Assert.True(root2.Child.Classes.Contains(":focus-within"));
+                Assert.True(root2.Child.IsKeyboardFocusWithin);
+                Assert.True(root2.Classes.Contains(":focus-within"));
+                Assert.True(root2.IsKeyboardFocusWithin);
+            }
+        }
     }
 }