From a8f3628da9c43ce740ce668c408e6e00f879c96e Mon Sep 17 00:00:00 2001 From: Melissa Date: Sat, 27 Sep 2025 13:50:44 +0200 Subject: [PATCH] Added HeadingLevel to AutomationProperties (#19696) * Added HeadingLevel to AutomationProperties * Added support for HeadlingLevel accessibility on Mac --- native/Avalonia.Native/src/OSX/automation.mm | 6 +++- .../Pages/AutomationPage.axaml | 9 ++++++ .../AutomationElementIdentifiers.cs | 6 ++++ .../Automation/AutomationProperties.cs | 30 +++++++++++++++++++ .../Automation/Peers/AutomationPeer.cs | 18 +++++++++++ .../Automation/Peers/ControlAutomationPeer.cs | 1 + src/Avalonia.Native/AvnAutomationPeer.cs | 1 + src/Avalonia.Native/avn.idl | 1 + .../AutomationNode.cs | 19 ++++++++++++ .../Interop/IRawElementProviderSimple.cs | 23 +++++++++++++- 10 files changed, 112 insertions(+), 2 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index 1847a83160..93a67a64e0 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -122,7 +122,7 @@ case AutomationSplitButton: return NSAccessibilityPopUpButtonRole; case AutomationWindow: return NSAccessibilityWindowRole; case AutomationPane: return NSAccessibilityGroupRole; - case AutomationHeader: return NSAccessibilityGroupRole; + case AutomationHeader: return @"AXHeading"; case AutomationHeaderItem: return NSAccessibilityButtonRole; case AutomationTable: return NSAccessibilityTableRole; case AutomationTitleBar: return NSAccessibilityGroupRole; @@ -176,6 +176,10 @@ { return GetNSStringAndRelease(_peer->GetName()); } + else if (_peer->GetAutomationControlType() == AutomationHeader) + { + return [NSNumber numberWithInt:_peer->GetHeadingLevel()]; + } return [super accessibilityValue]; } diff --git a/samples/IntegrationTestApp/Pages/AutomationPage.axaml b/samples/IntegrationTestApp/Pages/AutomationPage.axaml index 9d6ef43b2a..dcc1ee479c 100644 --- a/samples/IntegrationTestApp/Pages/AutomationPage.axaml +++ b/samples/IntegrationTestApp/Pages/AutomationPage.axaml @@ -13,5 +13,14 @@ Foo + + Header 1 + + + Header 2 + + + Header None + diff --git a/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs b/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs index 574979c77b..27d7bedcb9 100644 --- a/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs +++ b/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs @@ -30,5 +30,11 @@ namespace Avalonia.Automation /// by the method. /// public static AutomationProperty HelpTextProperty { get; } = new AutomationProperty(); + + /// + /// Identifiers the heading level automation property. The class name property value is returned + /// by the method. + /// + public static AutomationProperty HeadingLevelProperty { get; } = new AutomationProperty(); } } diff --git a/src/Avalonia.Controls/Automation/AutomationProperties.cs b/src/Avalonia.Controls/Automation/AutomationProperties.cs index 2762beab6b..989aa28935 100644 --- a/src/Avalonia.Controls/Automation/AutomationProperties.cs +++ b/src/Avalonia.Controls/Automation/AutomationProperties.cs @@ -104,6 +104,17 @@ namespace Avalonia.Automation "HelpText", typeof(AutomationProperties)); + /// + /// Defines the AutomationProperties.HeadingLevel attached property. + /// + /// + /// This property affects the default value for . + /// + public static readonly AttachedProperty HeadingLevelProperty = + AvaloniaProperty.RegisterAttached( + "HeadingLevel", + typeof(AutomationProperties)); + /// /// Defines the AutomationProperties.IsColumnHeader attached property. /// @@ -348,6 +359,25 @@ namespace Avalonia.Automation return element.GetValue(HelpTextProperty); } + /// + /// Helper for setting the value of the on a StyledElement. + /// + public static void SetHeadingLevel(StyledElement element, int value) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + element.SetValue(HeadingLevelProperty, value); + } + + /// + /// Helper for reading the value of the on a StyledElement. + /// + /// + public static int GetHeadingLevel(StyledElement element) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + return element.GetValue(HeadingLevelProperty); + } + /// /// Helper for setting the value of the on a StyledElement. /// diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs index 0b4653cddb..d46a24cd59 100644 --- a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs @@ -270,6 +270,23 @@ namespace Avalonia.Automation.Peers /// public string GetHelpText() => GetHelpTextCore() ?? string.Empty; + /// + /// Gets the heading level that is associated with this automation peer. + /// + /// + /// + /// + /// Windows + /// UIA_HeadingLevelPropertyId + /// + /// + /// macOS + /// NSAccessibilityProtocol.accessibilityValue + /// + /// + /// + public int GetHeadingLevel() => GetHeadingLevelCore(); + /// /// Gets the that is the parent of this . /// @@ -503,6 +520,7 @@ namespace Avalonia.Automation.Peers protected abstract AutomationPeer? GetLabeledByCore(); protected abstract string? GetNameCore(); protected virtual string? GetHelpTextCore() => null; + protected virtual int GetHeadingLevelCore() => 0; protected abstract AutomationPeer? GetParentCore(); protected abstract bool HasKeyboardFocusCore(); protected abstract bool IsContentElementCore(); diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index 7115399647..72ad641963 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -134,6 +134,7 @@ namespace Avalonia.Automation.Peers return result; } + protected override int GetHeadingLevelCore() => AutomationProperties.GetHeadingLevel(Owner); protected override AutomationPeer? GetParentCore() { EnsureConnected(); diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index 16ff99f7dc..df268100e9 100644 --- a/src/Avalonia.Native/AvnAutomationPeer.cs +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -40,6 +40,7 @@ namespace Avalonia.Native public IAvnAutomationPeer? LabeledBy => Wrap(_inner.GetLabeledBy()); public IAvnString Name => _inner.GetName().ToAvnString(); public IAvnString HelpText => _inner.GetHelpText().ToAvnString(); + public int HeadingLevel => _inner.GetHeadingLevel(); public IAvnAutomationPeer? Parent => Wrap(_inner.GetParent()); public IAvnAutomationPeer? VisualRoot => Wrap(_inner.GetVisualRoot()); diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 97afb8667e..ee9538ec0c 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -1238,6 +1238,7 @@ interface IAvnAutomationPeer : IUnknown void ValueProvider_SetValue(char* value); IAvnString* GetHelpText(); + int GetHeadingLevel(); } [uuid(b00af5da-78af-4b33-bfff-4ce13a6239a9)] diff --git a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs index 39698e6ecb..611507cba5 100644 --- a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs +++ b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs @@ -37,6 +37,7 @@ namespace Avalonia.Win32.Automation { AutomationElementIdentifiers.ClassNameProperty, UiaPropertyId.ClassName }, { AutomationElementIdentifiers.NameProperty, UiaPropertyId.Name }, { AutomationElementIdentifiers.HelpTextProperty, UiaPropertyId.HelpText }, + { AutomationElementIdentifiers.HeadingLevelProperty, UiaPropertyId.HeadingLevel }, { ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty, UiaPropertyId.ExpandCollapseExpandCollapseState }, { RangeValuePatternIdentifiers.IsReadOnlyProperty, UiaPropertyId.RangeValueIsReadOnly}, { RangeValuePatternIdentifiers.MaximumProperty, UiaPropertyId.RangeValueMaximum }, @@ -138,6 +139,7 @@ namespace Avalonia.Win32.Automation UiaPropertyId.LocalizedControlType => InvokeSync(() => Peer.GetLocalizedControlType()), UiaPropertyId.Name => InvokeSync(() => Peer.GetName()), UiaPropertyId.HelpText => InvokeSync(() => Peer.GetHelpText()), + UiaPropertyId.HeadingLevel => InvokeSync(() => ToUiaHeadingLevel(Peer.GetHeadingLevel())), UiaPropertyId.ProcessId => s_pid, UiaPropertyId.RuntimeId => _runtimeId, _ => null, @@ -358,6 +360,23 @@ namespace Avalonia.Win32.Automation }; } + private static UiaHeadingLevel ToUiaHeadingLevel(int level) + { + return level switch + { + 1 => UiaHeadingLevel.Level1, + 2 => UiaHeadingLevel.Level2, + 3 => UiaHeadingLevel.Level3, + 4 => UiaHeadingLevel.Level4, + 5 => UiaHeadingLevel.Level5, + 6 => UiaHeadingLevel.Level6, + 7 => UiaHeadingLevel.Level7, + 8 => UiaHeadingLevel.Level8, + 9 => UiaHeadingLevel.Level9, + _ => UiaHeadingLevel.None, + }; + } + private static int GetProcessId() { #if NET6_0_OR_GREATER diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs index c8c5d1ceee..71a5fc37a6 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs @@ -184,7 +184,14 @@ internal enum UiaPropertyId OutlineThickness, CenterPoint, Rotatation, - Size + Size, + IsSelectionPattern2Available, + Selection2FirstSelectedItem, + Selection2LastSelectedItem, + Selection2CurrentSelectedItem, + Selection2ItemCount, + HeadingLevel, + IsDialog } internal enum UiaPatternId @@ -270,6 +277,20 @@ internal enum UiaControlTypeId AppBar }; +internal enum UiaHeadingLevel +{ + None = 80050, + Level1, + Level2, + Level3, + Level4, + Level5, + Level6, + Level7, + Level8, + Level9 +}; + #if NET8_0_OR_GREATER [GeneratedComInterface] #else