Merge pull request #535 from irihitech/534-form

Fix Form A11y for dynamic generated FormItems.
This commit is contained in:
Dong Bin
2025-01-13 21:30:54 +08:00
committed by GitHub
10 changed files with 232 additions and 4 deletions

View File

@@ -59,6 +59,7 @@
HorizontalAlignment="{TemplateBinding LabelAlignment}" HorizontalAlignment="{TemplateBinding LabelAlignment}"
Orientation="Horizontal"> Orientation="Horizontal">
<Label <Label
Name="PART_Label"
Content="{TemplateBinding Label}" Content="{TemplateBinding Label}"
Background="Transparent" Background="Transparent"
FontWeight="{DynamicResource TextBlockTitleFontWeight}" FontWeight="{DynamicResource TextBlockTitleFontWeight}"
@@ -98,8 +99,7 @@
Name="PART_Label" Name="PART_Label"
Content="{TemplateBinding Label}" Content="{TemplateBinding Label}"
Background="Transparent" Background="Transparent"
FontWeight="{DynamicResource TextBlockTitleFontWeight}" FontWeight="{DynamicResource TextBlockTitleFontWeight}"/>
Target="{Binding #PART_ContentPresenter.Content}" />
<TextBlock <TextBlock
Foreground="{DynamicResource FormAsteriskForeground}" Foreground="{DynamicResource FormAsteriskForeground}"
IsVisible="{TemplateBinding IsRequired}" IsVisible="{TemplateBinding IsRequired}"

View File

@@ -1,6 +1,9 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Metadata; using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Reactive; using Avalonia.Reactive;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@@ -10,8 +13,10 @@ using Ursa.Common;
namespace Ursa.Controls; namespace Ursa.Controls;
[PseudoClasses(PC_Horizontal, PC_NoLabel)] [PseudoClasses(PC_Horizontal, PC_NoLabel)]
[TemplatePart(PART_Label, typeof(Label))]
public class FormItem: ContentControl public class FormItem: ContentControl
{ {
public const string PART_Label = "PART_Label";
public const string PC_Horizontal = ":horizontal"; public const string PC_Horizontal = ":horizontal";
public const string PC_NoLabel = ":no-label"; public const string PC_NoLabel = ":no-label";
@@ -34,6 +39,7 @@ public class FormItem: ContentControl
public static bool GetNoLabel(Control obj) => obj.GetValue(NoLabelProperty); public static bool GetNoLabel(Control obj) => obj.GetValue(NoLabelProperty);
#endregion #endregion
private Label? _label;
private List<IDisposable> _formSubscriptions = new List<IDisposable>(); private List<IDisposable> _formSubscriptions = new List<IDisposable>();
public static readonly StyledProperty<double> LabelWidthProperty = AvaloniaProperty.Register<FormItem, double>( public static readonly StyledProperty<double> LabelWidthProperty = AvaloniaProperty.Register<FormItem, double>(
@@ -57,6 +63,8 @@ public class FormItem: ContentControl
static FormItem() static FormItem()
{ {
NoLabelProperty.AffectsPseudoClass<FormItem>(PC_NoLabel); NoLabelProperty.AffectsPseudoClass<FormItem>(PC_NoLabel);
LabelProperty.Changed.AddClassHandler<FormItem>((o, e) => o.SetLabelTarget());
ContentProperty.Changed.AddClassHandler<FormItem>((o, e) => o.SetLabelTarget());
} }
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
@@ -81,6 +89,36 @@ public class FormItem: ContentControl
} }
} }
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_label = e.NameScope.Find<Label>(PART_Label);
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
SetLabelTarget();
}
private void SetLabelTarget()
{
if (_label is null) return;
// Set it directly if content is a control, this is faster than looking up logical tree.
if (Content is InputElement input)
{
_label.Target = input;
}
else
{
var logical = this.LogicalChildren.OfType<InputElement>().FirstOrDefault(a => a.Focusable);
if (logical is not null)
{
_label.Target = logical;
}
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnDetachedFromVisualTree(e); base.OnDetachedFromVisualTree(e);

View File

@@ -0,0 +1,26 @@
<UserControl
x:Class="HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests.DynamicForm"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:accessibilityTests="clr-namespace:HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:u="https://irihi.tech/ursa"
d:DesignHeight="450"
d:DesignWidth="800"
x:CompileBindings="True"
x:DataType="accessibilityTests:DynamicFormViewModel"
mc:Ignorable="d">
<u:Form Name="form" ItemsSource="{Binding Items}">
<u:Form.Styles>
<Style x:DataType="accessibilityTests:FormTextViewModel" Selector="u|FormItem">
<Setter Property="Label" Value="{Binding Label}" />
</Style>
</u:Form.Styles>
<u:Form.ItemTemplate>
<DataTemplate DataType="accessibilityTests:FormTextViewModel">
<TextBox Text="{Binding Value}" />
</DataTemplate>
</u:Form.ItemTemplate>
</u:Form>
</UserControl>

View File

@@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using CommunityToolkit.Mvvm.ComponentModel;
namespace HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests;
public partial class DynamicForm : UserControl
{
public DynamicForm()
{
InitializeComponent();
this.DataContext = new DynamicFormViewModel();
}
}
public partial class DynamicFormViewModel: ObservableObject
{
public ObservableCollection<FormTextViewModel> Items { get; set; } =
[
new() { Label = "_Name" },
new() { Label = "_Email" }
];
}
public partial class FormTextViewModel : ObservableObject
{
[ObservableProperty] private string? _label;
[ObservableProperty] private string? _value;
}

View File

@@ -0,0 +1,78 @@
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Ursa.Controls;
namespace HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests;
public class FormAccessibilityTests
{
[AvaloniaFact]
public void Static_Form_Inner_Control_Accessible()
{
var window = new Window();
var form = new StaticForm();
window.Content = form;
window.Show();
Assert.False(form.NameBox.IsFocused);
Assert.False(form.EmailBox.IsFocused);
window.KeyPressQwerty(PhysicalKey.N, RawInputModifiers.Alt);
Assert.True(form.NameBox.IsFocused);
Assert.False(form.EmailBox.IsFocused);
window.KeyPressQwerty(PhysicalKey.E, RawInputModifiers.Alt);
Assert.False(form.NameBox.IsFocused);
Assert.True(form.EmailBox.IsFocused);
}
[AvaloniaFact]
public void Static_Form_With_FormItem_Accessible()
{
var window = new Window();
var form = new StaticForm2();
window.Content = form;
window.Show();
Assert.False(form.NameBox.IsFocused);
Assert.False(form.EmailBox.IsFocused);
window.KeyPressQwerty(PhysicalKey.N, RawInputModifiers.Alt);
Assert.True(form.NameBox.IsFocused);
Assert.False(form.EmailBox.IsFocused);
window.KeyPressQwerty(PhysicalKey.E, RawInputModifiers.Alt);
Assert.False(form.NameBox.IsFocused);
Assert.True(form.EmailBox.IsFocused);
}
[AvaloniaFact]
public void Dynamic_Form_Inner_Control_Accessible()
{
var window = new Window();
var form = new DynamicForm();
window.Content = form;
window.Show();
var logicalChildren = form.form.GetLogicalChildren().ToList();
Assert.Equal(2, logicalChildren.Count);
var first = logicalChildren[0] as FormItem;
var second = logicalChildren[1] as FormItem;
Assert.NotNull(first);
Assert.NotNull(second);
var text1 = first.GetLogicalChildren().OfType<TextBox>().FirstOrDefault();
var text2 = second.GetLogicalChildren().OfType<TextBox>().FirstOrDefault();
Assert.NotNull(text1);
Assert.NotNull(text2);
Assert.False(text1.IsFocused);
Assert.False(text2.IsFocused);
window.KeyPressQwerty(PhysicalKey.N, RawInputModifiers.Alt);
Assert.True(text1.IsFocused);
Assert.False(text2.IsFocused);
window.KeyPressQwerty(PhysicalKey.E, RawInputModifiers.Alt);
Assert.False(text1.IsFocused);
Assert.True(text2.IsFocused);
}
}

View File

@@ -0,0 +1,13 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:u="https://irihi.tech/ursa"
mc:Ignorable="d" d:DesignWidth="800"
d:DesignHeight="450"
x:Class="HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests.StaticForm">
<u:Form LabelWidth="200" LabelPosition="Left" LabelAlignment="Left">
<TextBox Name="NameBox" Width="300" u:FormItem.Label="_Name" />
<TextBox Name="EmailBox" Width="300" u:FormItem.Label="_Email" />
</u:Form>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests;
public partial class StaticForm : UserControl
{
public StaticForm()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,17 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:u="https://irihi.tech/ursa"
mc:Ignorable="d" d:DesignWidth="800"
d:DesignHeight="450"
x:Class="HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests.StaticForm2">
<u:Form LabelWidth="200" LabelPosition="Left" LabelAlignment="Left">
<u:FormItem Label="_Name">
<TextBox Name="NameBox" Width="300" />
</u:FormItem>
<u:FormItem Label="_Email">
<TextBox Name="EmailBox" Width="300" />
</u:FormItem>
</u:Form>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace HeadlessTest.Ursa.Controls.FormTests.AccessibilityTests;
public partial class StaticForm2 : UserControl
{
public StaticForm2()
{
InitializeComponent();
}
}

View File

@@ -4,7 +4,6 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
</PropertyGroup> </PropertyGroup>