diff --git a/src/Avalonia.Input/ApiCompatBaseline.txt b/src/Avalonia.Input/ApiCompatBaseline.txt
new file mode 100644
index 0000000000..d960664c1b
--- /dev/null
+++ b/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
diff --git a/src/Avalonia.Input/IInputElement.cs b/src/Avalonia.Input/IInputElement.cs
index 12fec82368..7aa9c32bca 100644
--- a/src/Avalonia.Input/IInputElement.cs
+++ b/src/Avalonia.Input/IInputElement.cs
@@ -89,6 +89,11 @@ namespace Avalonia.Input
/// value of this control and its parent controls.
///
bool IsEffectivelyEnabled { get; }
+
+ ///
+ /// Gets a value indicating whether keyboard focus is anywhere within the element or its visual tree child elements.
+ ///
+ bool IsKeyboardFocusWithin { get; }
///
/// Gets a value indicating whether the control is focused.
diff --git a/src/Avalonia.Input/InputElement.cs b/src/Avalonia.Input/InputElement.cs
index 9ace7fd92d..66fb9cfb1c 100644
--- a/src/Avalonia.Input/InputElement.cs
+++ b/src/Avalonia.Input/InputElement.cs
@@ -42,6 +42,14 @@ namespace Avalonia.Input
public static readonly StyledProperty CursorProperty =
AvaloniaProperty.Register(nameof(Cursor), null, true);
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty IsKeyboardFocusWithinProperty =
+ AvaloniaProperty.RegisterDirect(
+ nameof(IsKeyboardFocusWithin),
+ o => o.IsKeyboardFocusWithin);
+
///
/// Defines the property.
///
@@ -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); }
}
+
+ ///
+ /// Gets a value indicating whether keyboard focus is anywhere within the element or its visual tree child elements.
+ ///
+ public bool IsKeyboardFocusWithin
+ {
+ get => _isKeyboardFocusWithin;
+ internal set => SetAndRaise(IsKeyboardFocusWithinProperty, ref _isKeyboardFocusWithin, value);
+ }
///
/// Gets a value indicating whether the control is focused.
@@ -544,6 +562,10 @@ namespace Avalonia.Input
{
UpdatePseudoClasses(null, change.NewValue.GetValueOrDefault());
}
+ else if (change.Property == IsKeyboardFocusWithinProperty)
+ {
+ PseudoClasses.Set(":focus-within", _isKeyboardFocusWithin);
+ }
}
///
diff --git a/src/Avalonia.Input/KeyboardDevice.cs b/src/Avalonia.Input/KeyboardDevice.cs
index 187670a26b..6f4cb7a35c 100644
--- a/src/Avalonia.Input/KeyboardDevice.cs
+++ b/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
diff --git a/tests/Avalonia.Input.UnitTests/InputElement_Focus.cs b/tests/Avalonia.Input.UnitTests/InputElement_Focus.cs
index 09fae7207f..8b8f2fa775 100644
--- a/tests/Avalonia.Input.UnitTests/InputElement_Focus.cs
+++ b/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);
+ }
+ }
}
}