diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index 93a67a64e0..fccb53b7aa 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -133,6 +133,38 @@ } } +- (NSAccessibilitySubrole)accessibilitySubrole +{ + auto landmarkType = _peer->GetLandmarkType(); + switch (landmarkType) { + case LandmarkBanner: return @"AXLandmarkBanner"; + case LandmarkComplementary: return @"AXLandmarkComplementary"; + case LandmarkContentInfo: return @"AXLandmarkContentInfo"; + case LandmarkRegion: return @"AXLandmarkRegion"; + case LandmarkForm: return @"AXLandmarkForm"; + case LandmarkMain: return @"AXLandmarkMain"; + case LandmarkNavigation: return @"AXLandmarkNavigation"; + case LandmarkSearch: return @"AXLandmarkSearch"; + default: return NSAccessibilityUnknownSubrole; + } +} + +- (NSString *)accessibilityRoleDescription +{ + auto landmarkType = _peer->GetLandmarkType(); + switch (landmarkType) { + case LandmarkBanner: return @"banner"; + case LandmarkComplementary: return @"complementary"; + case LandmarkContentInfo: return @"content"; + case LandmarkRegion: return @"region"; + case LandmarkForm: return @"form"; + case LandmarkMain: return @"main"; + case LandmarkNavigation: return @"navigation"; + case LandmarkSearch: return @"search"; + } + return NSAccessibilityRoleDescription([self accessibilityRole], [self accessibilitySubrole]); +} + - (NSString *)accessibilityIdentifier { return GetNSStringAndRelease(_peer->GetAutomationId()); diff --git a/samples/IntegrationTestApp/MainWindow.axaml b/samples/IntegrationTestApp/MainWindow.axaml index effed7964a..a03f6ea30a 100644 --- a/samples/IntegrationTestApp/MainWindow.axaml +++ b/samples/IntegrationTestApp/MainWindow.axaml @@ -37,14 +37,17 @@ DisplayMemberBinding="{Binding Name}" ItemsSource="{Binding Pages}" SelectedItem="{Binding SelectedPage}" - SelectionChanged="Pager_SelectionChanged"> + SelectionChanged="Pager_SelectionChanged" + AutomationProperties.LandmarkType="Navigation"> - + diff --git a/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs b/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs index 27d7bedcb9..8cdee8e3fe 100644 --- a/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs +++ b/src/Avalonia.Controls/Automation/AutomationElementIdentifiers.cs @@ -32,7 +32,13 @@ namespace Avalonia.Automation public static AutomationProperty HelpTextProperty { get; } = new AutomationProperty(); /// - /// Identifiers the heading level automation property. The class name property value is returned + /// Identifies the landmark type automation property. The class name property value is returned + /// by the method. + /// + public static AutomationProperty LandmarkTypeProperty { get; } = new AutomationProperty(); + + /// + /// Identifies 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 989aa28935..4e11715c56 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.LandmarkType attached property. + /// + /// + /// This property affects the default value for + /// + public static readonly AttachedProperty LandmarkTypeProperty = + AvaloniaProperty.RegisterAttached( + "LandmarkType", + typeof(AutomationProperties)); + /// /// Defines the AutomationProperties.HeadingLevel attached property. /// @@ -359,6 +370,24 @@ namespace Avalonia.Automation return element.GetValue(HelpTextProperty); } + /// + /// Helper for setting the value of the on a StyledElement. + /// + public static void SetLandmarkType(StyledElement element, AutomationLandmarkType? value) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + element.SetValue(LandmarkTypeProperty, value); + } + + /// + /// Helper for reading the value of the on a StyledElement. + /// + public static AutomationLandmarkType? GetLandmarkType(StyledElement element) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + return element.GetValue(LandmarkTypeProperty); + } + /// /// Helper for setting the value of the on a StyledElement. /// @@ -371,7 +400,6 @@ namespace Avalonia.Automation /// /// Helper for reading the value of the on a StyledElement. /// - /// public static int GetHeadingLevel(StyledElement element) { _ = element ?? throw new ArgumentNullException(nameof(element)); diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs index d46a24cd59..65f9503061 100644 --- a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs @@ -49,6 +49,18 @@ namespace Avalonia.Automation.Peers Separator, } + public enum AutomationLandmarkType + { + Banner, + Complementary, + ContentInfo, + Region, + Form, + Main, + Navigation, + Search, + } + /// /// Provides a base class that exposes an element to UI Automation. /// @@ -270,6 +282,25 @@ namespace Avalonia.Automation.Peers /// public string GetHelpText() => GetHelpTextCore() ?? string.Empty; + /// + /// Gets the control type for the element that is associated with the UI Automation peer. + /// + /// + /// Gets the type of the element. + /// + /// + /// + /// Windows + /// UIA_LandmarkTypePropertyId, UIA_LocalizedLandmarkTypePropertyId + /// + /// + /// macOS + /// NSAccessibilityProtocol.accessibilityRole, NSAccessibilityProtocol.accessibilitySubrole + /// + /// + /// + public AutomationLandmarkType? GetLandmarkType() => GetLandmarkTypeCore(); + /// /// Gets the heading level that is associated with this automation peer. /// @@ -505,6 +536,7 @@ namespace Avalonia.Automation.Peers AutomationControlType.SplitButton => "split button", AutomationControlType.HeaderItem => "header item", AutomationControlType.TitleBar => "title bar", + AutomationControlType.None => (GetLandmarkType()?.ToString() ?? controlType.ToString()).ToLowerInvariant(), _ => controlType.ToString().ToLowerInvariant(), }; } @@ -520,6 +552,7 @@ namespace Avalonia.Automation.Peers protected abstract AutomationPeer? GetLabeledByCore(); protected abstract string? GetNameCore(); protected virtual string? GetHelpTextCore() => null; + protected virtual AutomationLandmarkType? GetLandmarkTypeCore() => null; protected virtual int GetHeadingLevelCore() => 0; protected abstract AutomationPeer? GetParentCore(); protected abstract bool HasKeyboardFocusCore(); diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index 72ad641963..ef14be0169 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 AutomationLandmarkType? GetLandmarkTypeCore() => AutomationProperties.GetLandmarkType(Owner); protected override int GetHeadingLevelCore() => AutomationProperties.GetHeadingLevel(Owner); protected override AutomationPeer? GetParentCore() { diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index df268100e9..c666ffc695 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 AvnLandmarkType LandmarkType => (AvnLandmarkType?)_inner.GetLandmarkType() ?? AvnLandmarkType.LandmarkNone; 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 5e68635511..b80da03bd7 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -654,6 +654,19 @@ enum AvnAutomationControlType AutomationSeparator, } +enum AvnLandmarkType +{ + LandmarkNone = -1, + LandmarkBanner, + LandmarkComplementary, + LandmarkContentInfo, + LandmarkRegion, + LandmarkForm, + LandmarkMain, + LandmarkNavigation, + LandmarkSearch, +} + enum AvnWindowTransparencyMode { Opaque, @@ -1307,6 +1320,8 @@ interface IAvnAutomationPeer : IUnknown void ValueProvider_SetValue(char* value); IAvnString* GetHelpText(); + + AvnLandmarkType GetLandmarkType(); int GetHeadingLevel(); } diff --git a/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32.Automation/AutomationNode.cs index 611507cba5..45216a4c09 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.LandmarkTypeProperty, UiaPropertyId.LandmarkType }, { AutomationElementIdentifiers.HeadingLevelProperty, UiaPropertyId.HeadingLevel }, { ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty, UiaPropertyId.ExpandCollapseExpandCollapseState }, { RangeValuePatternIdentifiers.IsReadOnlyProperty, UiaPropertyId.RangeValueIsReadOnly}, @@ -139,6 +140,8 @@ namespace Avalonia.Win32.Automation UiaPropertyId.LocalizedControlType => InvokeSync(() => Peer.GetLocalizedControlType()), UiaPropertyId.Name => InvokeSync(() => Peer.GetName()), UiaPropertyId.HelpText => InvokeSync(() => Peer.GetHelpText()), + UiaPropertyId.LandmarkType => InvokeSync(() => ToUiaLandmarkType(Peer.GetLandmarkType())), + UiaPropertyId.LocalizedLandmarkType => InvokeSync(() => ToUiaLocalizedLandmarkType(Peer.GetLandmarkType())), UiaPropertyId.HeadingLevel => InvokeSync(() => ToUiaHeadingLevel(Peer.GetHeadingLevel())), UiaPropertyId.ProcessId => s_pid, UiaPropertyId.RuntimeId => _runtimeId, @@ -360,6 +363,34 @@ namespace Avalonia.Win32.Automation }; } + private static UiaLandmarkType? ToUiaLandmarkType(AutomationLandmarkType? landmarkType) + { + return landmarkType switch + { + AutomationLandmarkType.Banner or + AutomationLandmarkType.Complementary or + AutomationLandmarkType.ContentInfo or + AutomationLandmarkType.Region => UiaLandmarkType.Custom, + AutomationLandmarkType.Form => UiaLandmarkType.Form, + AutomationLandmarkType.Main => UiaLandmarkType.Main, + AutomationLandmarkType.Navigation => UiaLandmarkType.Navigation, + AutomationLandmarkType.Search => UiaLandmarkType.Search, + _ => null, + }; + } + + private static string? ToUiaLocalizedLandmarkType(AutomationLandmarkType? landmarkType) + { + return landmarkType switch + { + AutomationLandmarkType.Banner => "banner", + AutomationLandmarkType.Complementary => "complementary", + AutomationLandmarkType.ContentInfo => "content information", + AutomationLandmarkType.Region => "region", + _ => null, + }; + } + private static UiaHeadingLevel ToUiaHeadingLevel(int level) { return level switch diff --git a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs index 71a5fc37a6..bbbb2a29e2 100644 --- a/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs +++ b/src/Windows/Avalonia.Win32.Automation/Interop/IRawElementProviderSimple.cs @@ -277,6 +277,15 @@ internal enum UiaControlTypeId AppBar }; +internal enum UiaLandmarkType +{ + Custom = 80000, + Form, + Main, + Navigation, + Search, +}; + internal enum UiaHeadingLevel { None = 80050,