From a4dce564a9b51485be956123970e725330372c0f Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Sat, 21 Feb 2026 01:21:06 +1100 Subject: [PATCH 01/11] Implement remainder of ISelectionItemProvider on macOS The mapping proposed in the doc comments is modified because there is no direct concept of multiple selection in VoiceOver. `Select()` maps to both `-accessibilityPerformPick` and `-setAccessibilitySelected:YES`, with `RemoveFromSelection()` mapped to `-setAccessibilitySelected:NO`. This feels like it better maps to what the API intends. --- native/Avalonia.Native/src/OSX/automation.mm | 19 +++++++++++++++++++ .../Provider/ISelectionItemProvider .cs | 11 +++-------- src/Avalonia.Native/AvnAutomationPeer.cs | 5 ++++- src/Avalonia.Native/avn.idl | 5 ++++- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index b42dc22f7a7..34b8cad9516 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -191,6 +191,7 @@ - (id)accessibilityAttributeValue:(NSAccessibilityAttributeName)attribute { switch (_peer->GetLiveSetting()) { + case LiveSettingOff: break; case LiveSettingPolite: return @"polite"; case LiveSettingAssertive: return @"assertive"; } @@ -401,6 +402,24 @@ - (BOOL)isAccessibilitySelected return NO; } +- (void)setAccessibilitySelected:(BOOL)accessibilitySelected +{ + if (!_peer->IsSelectionItemProvider()) + return; + if (accessibilitySelected) + _peer->SelectionItemProvider_Select(); + else + _peer->SelectionItemProvider_RemoveFromSelection(); +} + +- (BOOL)accessibilityPerformPick +{ + if (!_peer->IsSelectionItemProvider()) + return NO; + _peer->SelectionItemProvider_Select(); + return YES; +} + - (BOOL)isAccessibilitySelectorAllowed:(SEL)selector { if (selector == @selector(accessibilityPerformShowMenu)) diff --git a/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs b/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs index 22fd4d33d47..041438bc237 100644 --- a/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs +++ b/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs @@ -52,10 +52,7 @@ public interface ISelectionItemProvider /// /// /// macOS - /// - /// NSAccessibilityProtocol.accessibilityPerformPick (not implemented). - /// NSAccessibilityProtocol.setAccessibilitySelected (not implemented). - /// + /// No mapping. /// /// /// @@ -72,9 +69,7 @@ public interface ISelectionItemProvider /// /// /// macOS - /// - /// NSAccessibilityProtocol.setAccessibilitySelected (not implemented). - /// + /// NSAccessibilityProtocol.setAccessibilitySelected /// /// /// @@ -91,7 +86,7 @@ public interface ISelectionItemProvider /// /// /// macOS - /// No mapping. + /// NSAccessibilityProtocol.setAccessibilitySelected /// /// /// diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index cb418261f79..2bcf3cf9397 100644 --- a/src/Avalonia.Native/AvnAutomationPeer.cs +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -175,7 +175,10 @@ public IAvnAutomationPeer? RootPeer public int IsSelectionItemProvider() => IsProvider(); public int SelectionItemProvider_IsSelected() => SelectionItemProvider.IsSelected.AsComBool(); - + public void SelectionItemProvider_AddToSelection() => SelectionItemProvider.AddToSelection(); + public void SelectionItemProvider_RemoveFromSelection() => SelectionItemProvider.RemoveFromSelection(); + public void SelectionItemProvider_Select() => SelectionItemProvider.Select(); + public int IsToggleProvider() => IsProvider(); public int ToggleProvider_GetToggleState() => (int)ToggleProvider.ToggleState; public void ToggleProvider_Toggle() => ToggleProvider.Toggle(); diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index a224eeea450..99af1246dca 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -1319,7 +1319,10 @@ interface IAvnAutomationPeer : IUnknown bool IsSelectionItemProvider(); bool SelectionItemProvider_IsSelected(); - + void SelectionItemProvider_AddToSelection(); + void SelectionItemProvider_RemoveFromSelection(); + void SelectionItemProvider_Select(); + bool IsToggleProvider(); int ToggleProvider_GetToggleState(); void ToggleProvider_Toggle(); From a6b56377f65e8aff8b81e47d521dd00ae3a3c939 Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Sat, 21 Feb 2026 15:59:00 +1100 Subject: [PATCH 02/11] Implement virtualized automation children for ListBox This creates an automation peer for ListBox that returns actual or virtual automation peers for the items in the list. This allows VoiceOver to be aware of the number of items, and be able to navigate between them, even if not visible in the current viewport. Fixes #20685. --- .../Automation/Peers/ListBoxAutomationPeer.cs | 41 +++++++ .../Peers/VirtualListItemAutomationPeer.cs | 104 ++++++++++++++++++ src/Avalonia.Controls/ListBox.cs | 6 + 3 files changed, 151 insertions(+) create mode 100644 src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs create mode 100644 src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs new file mode 100644 index 00000000000..414c91ddd2e --- /dev/null +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Avalonia.Controls; + +namespace Avalonia.Automation.Peers +{ + public class ListBoxAutomationPeer : SelectingItemsControlAutomationPeer + { + public ListBoxAutomationPeer(ListBox owner) + : base(owner) + { + } + + public new ListBox Owner => (ListBox)base.Owner; + + protected override IReadOnlyList? GetChildrenCore() + { + if (Owner.ItemCount == 0) + { + return null; + } + + var children = new List(); + + for (var i = 0; i < Owner.ItemCount; i++) + { + var container = Owner.ContainerFromIndex(i); + + if (container is Control control) + { + children.Add(GetOrCreate(control)); + } + else + { + children.Add(new VirtualListItemAutomationPeer(Owner, i)); + } + } + + return children; + } + } +} diff --git a/src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs new file mode 100644 index 00000000000..f14c1913dde --- /dev/null +++ b/src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation.Provider; +using Avalonia.Controls; +using Avalonia.Controls.Selection; +using Avalonia.Controls.Utils; + +namespace Avalonia.Automation.Peers +{ + /// + /// An item in a that is not in the visible viewport. This allows screen + /// readers to be aware of the number of items, and to enable navigation to items outside the + /// viewport. When the item becomes visible, this class will be replaced in the automation tree + /// with the actual automation peer. + /// + internal class VirtualListItemAutomationPeer(ListBox listBox, int index) + : AutomationPeer, ISelectionItemProvider + { + public bool IsSelected => listBox.Selection.SelectedIndexes.Contains(index); + + public ISelectionProvider? SelectionContainer + { + get + { + var peer = listBox.GetOrCreateAutomationPeer(); + return peer.GetProvider(); + } + } + + public void Select() + { + EnsureEnabled(); + + listBox.SelectedIndex = index; + BringIntoView(); + } + + public void AddToSelection() + { + EnsureEnabled(); + + if (listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) + { + selectionModel.Select(index); + BringIntoView(); + } + } + + public void RemoveFromSelection() + { + EnsureEnabled(); + + if (listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) + { + selectionModel.Deselect(index); + } + } + + protected override void BringIntoViewCore() + { + var container = listBox.ContainerFromIndex(index); + container?.BringIntoView(); + } + + protected override string? GetAcceleratorKeyCore() => null; + protected override string? GetAccessKeyCore() => null; + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.ListItem; + protected override string GetAutomationIdCore() => $"ListBoxItem_{index}"; + + protected override Rect GetBoundingRectangleCore() + { + var container = listBox.ContainerFromIndex(index); + var peer = container?.GetOrCreateAutomationPeer(); + return peer?.GetBoundingRectangle() ?? default; + } + + protected override IReadOnlyList GetOrCreateChildrenCore() => []; + protected override string GetClassNameCore() => "ListBoxItem"; + protected override AutomationPeer? GetLabeledByCore() => null; + + protected override string? GetNameCore() + { + using var textBindingEvaluator = BindingEvaluator.TryCreate(listBox.DisplayMemberBinding); + var item = listBox.Items.ElementAtOrDefault(index); + return textBindingEvaluator?.Evaluate(item); + } + + protected override AutomationPeer? GetParentCore() => listBox.GetOrCreateAutomationPeer(); + protected override bool HasKeyboardFocusCore() => false; + protected override bool IsContentElementCore() => true; + protected override bool IsControlElementCore() => true; + protected override bool IsEnabledCore() => listBox.IsEnabled; + protected override bool IsKeyboardFocusableCore() => true; + + protected override void SetFocusCore() + { + listBox.SelectedIndex = index; + BringIntoView(); + } + + protected override bool ShowContextMenuCore() => false; + protected internal override bool TrySetParent(AutomationPeer? parent) => true; + } +} diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 3ef75f49901..d4b2d3c06a1 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Drawing; using System.Linq; +using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Controls.Selection; @@ -155,5 +156,10 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) base.OnApplyTemplate(e); Scroll = e.NameScope.Find("PART_ScrollViewer"); } + + protected override AutomationPeer OnCreateAutomationPeer() + { + return new ListBoxAutomationPeer(this); + } } } From e16c0fb319207ce599609a5162eed7bb398a64d8 Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Sat, 21 Feb 2026 17:50:10 +1100 Subject: [PATCH 03/11] Tidy up unneeded variable --- .../Automation/Peers/ListBoxAutomationPeer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs index 414c91ddd2e..5218e2858eb 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -25,13 +25,13 @@ public ListBoxAutomationPeer(ListBox owner) { var container = Owner.ContainerFromIndex(i); - if (container is Control control) + if (container == null) { - children.Add(GetOrCreate(control)); + children.Add(new VirtualListItemAutomationPeer(Owner, i)); } else { - children.Add(new VirtualListItemAutomationPeer(Owner, i)); + children.Add(GetOrCreate(container)); } } From dfba0b95514c538a9eb16ab11259975cd4308313 Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Sat, 21 Feb 2026 22:30:20 +1100 Subject: [PATCH 04/11] Refactor to model ListBoxAutomationPeer after ComboBoxAutomationPeer These serve similar behaviors, and the unrealized/virtual element peer can subclass from UnrealizedElementAutomationPeer. --- .../Automation/Peers/ListBoxAutomationPeer.cs | 85 +++++++++++++- .../Peers/VirtualListItemAutomationPeer.cs | 104 ------------------ 2 files changed, 84 insertions(+), 105 deletions(-) delete mode 100644 src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs index 5218e2858eb..9b431c9bc34 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -1,5 +1,9 @@ using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation.Provider; using Avalonia.Controls; +using Avalonia.Controls.Selection; +using Avalonia.Controls.Utils; namespace Avalonia.Automation.Peers { @@ -27,7 +31,7 @@ public ListBoxAutomationPeer(ListBox owner) if (container == null) { - children.Add(new VirtualListItemAutomationPeer(Owner, i)); + children.Add(new UnrealizedListItemAutomationPeer(this, i)); } else { @@ -37,5 +41,84 @@ public ListBoxAutomationPeer(ListBox owner) return children; } + + internal class UnrealizedListItemAutomationPeer(ListBoxAutomationPeer owner, int index) + : UnrealizedElementAutomationPeer, ISelectionItemProvider + { + private ListBox _listBox => owner.Owner; + + public bool IsSelected => _listBox.Selection.SelectedIndexes.Contains(index); + + public ISelectionProvider? SelectionContainer => owner.GetProvider(); + + public void Select() + { + EnsureEnabled(); + + _listBox.SelectedIndex = index; + BringIntoView(); + } + + public void AddToSelection() + { + EnsureEnabled(); + + if (_listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) + { + selectionModel.Select(index); + BringIntoView(); + } + } + + public void RemoveFromSelection() + { + EnsureEnabled(); + + if (_listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) + { + selectionModel.Deselect(index); + } + } + + protected override void BringIntoViewCore() + { + var container = _listBox.ContainerFromIndex(index); + container?.BringIntoView(); + } + + protected override string? GetAcceleratorKeyCore() => null; + protected override string? GetAccessKeyCore() => null; + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.ListItem; + protected override string GetAutomationIdCore() => $"{GetClassNameCore()}: {index}"; + + protected override Rect GetBoundingRectangleCore() + { + var container = _listBox.ContainerFromIndex(index); + var peer = container?.GetOrCreateAutomationPeer(); + return peer?.GetBoundingRectangle() ?? base.GetBoundingRectangleCore(); + } + + protected override string GetClassNameCore() => nameof(ListBoxItem); + protected override AutomationPeer? GetLabeledByCore() => null; + + protected override string? GetNameCore() + { + using var textBindingEvaluator = BindingEvaluator.TryCreate(_listBox.DisplayMemberBinding); + var item = _listBox.Items.ElementAtOrDefault(index); + return textBindingEvaluator?.Evaluate(item); + } + + protected override AutomationPeer? GetParentCore() => owner; + protected override bool IsContentElementCore() => true; + protected override bool IsControlElementCore() => true; + protected override bool IsEnabledCore() => _listBox.IsEnabled; + protected override bool IsKeyboardFocusableCore() => true; + + protected override void SetFocusCore() + { + _listBox.SelectedIndex = index; + BringIntoView(); + } + } } } diff --git a/src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs deleted file mode 100644 index f14c1913dde..00000000000 --- a/src/Avalonia.Controls/Automation/Peers/VirtualListItemAutomationPeer.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Avalonia.Automation.Provider; -using Avalonia.Controls; -using Avalonia.Controls.Selection; -using Avalonia.Controls.Utils; - -namespace Avalonia.Automation.Peers -{ - /// - /// An item in a that is not in the visible viewport. This allows screen - /// readers to be aware of the number of items, and to enable navigation to items outside the - /// viewport. When the item becomes visible, this class will be replaced in the automation tree - /// with the actual automation peer. - /// - internal class VirtualListItemAutomationPeer(ListBox listBox, int index) - : AutomationPeer, ISelectionItemProvider - { - public bool IsSelected => listBox.Selection.SelectedIndexes.Contains(index); - - public ISelectionProvider? SelectionContainer - { - get - { - var peer = listBox.GetOrCreateAutomationPeer(); - return peer.GetProvider(); - } - } - - public void Select() - { - EnsureEnabled(); - - listBox.SelectedIndex = index; - BringIntoView(); - } - - public void AddToSelection() - { - EnsureEnabled(); - - if (listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) - { - selectionModel.Select(index); - BringIntoView(); - } - } - - public void RemoveFromSelection() - { - EnsureEnabled(); - - if (listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) - { - selectionModel.Deselect(index); - } - } - - protected override void BringIntoViewCore() - { - var container = listBox.ContainerFromIndex(index); - container?.BringIntoView(); - } - - protected override string? GetAcceleratorKeyCore() => null; - protected override string? GetAccessKeyCore() => null; - protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.ListItem; - protected override string GetAutomationIdCore() => $"ListBoxItem_{index}"; - - protected override Rect GetBoundingRectangleCore() - { - var container = listBox.ContainerFromIndex(index); - var peer = container?.GetOrCreateAutomationPeer(); - return peer?.GetBoundingRectangle() ?? default; - } - - protected override IReadOnlyList GetOrCreateChildrenCore() => []; - protected override string GetClassNameCore() => "ListBoxItem"; - protected override AutomationPeer? GetLabeledByCore() => null; - - protected override string? GetNameCore() - { - using var textBindingEvaluator = BindingEvaluator.TryCreate(listBox.DisplayMemberBinding); - var item = listBox.Items.ElementAtOrDefault(index); - return textBindingEvaluator?.Evaluate(item); - } - - protected override AutomationPeer? GetParentCore() => listBox.GetOrCreateAutomationPeer(); - protected override bool HasKeyboardFocusCore() => false; - protected override bool IsContentElementCore() => true; - protected override bool IsControlElementCore() => true; - protected override bool IsEnabledCore() => listBox.IsEnabled; - protected override bool IsKeyboardFocusableCore() => true; - - protected override void SetFocusCore() - { - listBox.SelectedIndex = index; - BringIntoView(); - } - - protected override bool ShowContextMenuCore() => false; - protected internal override bool TrySetParent(AutomationPeer? parent) => true; - } -} From 78fc0046e5046e0dbda98d1dcbae969e032e2a08 Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Sun, 22 Feb 2026 22:08:34 +1100 Subject: [PATCH 05/11] Match automation IDs between unrealised and realised list items This ensures the screen reader follows the item when it transitions from unrealised to realised. Otherwise, navigation may be random. --- .../Automation/Peers/ListBoxAutomationPeer.cs | 7 ++-- .../Peers/ListItemAutomationPeer.cs | 34 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs index 9b431c9bc34..2ff2e38f46a 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -35,7 +35,10 @@ public ListBoxAutomationPeer(ListBox owner) } else { - children.Add(GetOrCreate(container)); + var peer = GetOrCreate(container); + if (peer is ListItemAutomationPeer listItemPeer) + listItemPeer.Index = i; + children.Add(peer); } } @@ -89,7 +92,7 @@ protected override void BringIntoViewCore() protected override string? GetAcceleratorKeyCore() => null; protected override string? GetAccessKeyCore() => null; protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.ListItem; - protected override string GetAutomationIdCore() => $"{GetClassNameCore()}: {index}"; + protected override string GetAutomationIdCore() => $"{nameof(ListBoxItem)}: {index}"; protected override Rect GetBoundingRectangleCore() { diff --git a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs index dab8c455671..0fe1ef29b5b 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs @@ -8,6 +8,24 @@ namespace Avalonia.Automation.Peers public class ListItemAutomationPeer : ContentControlAutomationPeer, ISelectionItemProvider { + private int? _index; + + public int Index + { + get + { + if (_index.HasValue) + return _index.Value; + + if (Owner.Parent is ItemsControl parent) + return parent.IndexFromContainer(Owner); + + return -1; + } + + set => _index = value; + } + public ListItemAutomationPeer(ContentControl owner) : base(owner) { @@ -35,7 +53,7 @@ public void Select() if (Owner.Parent is SelectingItemsControl parent) { - var index = parent.IndexFromContainer(Owner); + var index = Index; if (index != -1) parent.SelectedIndex = index; @@ -49,7 +67,7 @@ void ISelectionItemProvider.AddToSelection() if (Owner.Parent is ItemsControl parent && parent.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) { - var index = parent.IndexFromContainer(Owner); + var index = Index; if (index != -1) selectionModel.Select(index); @@ -63,7 +81,7 @@ void ISelectionItemProvider.RemoveFromSelection() if (Owner.Parent is ItemsControl parent && parent.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) { - var index = parent.IndexFromContainer(Owner); + var index = Index; if (index != -1) selectionModel.Deselect(index); @@ -75,6 +93,16 @@ protected override AutomationControlType GetAutomationControlTypeCore() return AutomationControlType.ListItem; } + protected override string? GetAutomationIdCore() + { + var index = Index; + + if (index != -1) + return base.GetAutomationIdCore(); + + return $"{nameof(ListBoxItem)}: {index}"; + } + protected override bool IsContentElementCore() => true; protected override bool IsControlElementCore() => true; } From c8c1d8b07d23cfaae38b6dff9db3170523c8f2dd Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Sun, 22 Feb 2026 23:05:35 +1100 Subject: [PATCH 06/11] Invalidate automation children when item count changes --- .../Automation/Peers/ListBoxAutomationPeer.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs index 2ff2e38f46a..ba483b30027 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -14,6 +14,14 @@ public ListBoxAutomationPeer(ListBox owner) { } + protected override void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + base.OwnerPropertyChanged(sender, e); + + if (e.Property == ItemsControl.ItemCountProperty) + InvalidateChildren(); + } + public new ListBox Owner => (ListBox)base.Owner; protected override IReadOnlyList? GetChildrenCore() From bbb02f766925a0914977a931b742068905cffaec Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Mon, 23 Feb 2026 10:56:59 +1100 Subject: [PATCH 07/11] Update automation peer with the item index on ContainerPrepared --- .../Automation/Peers/ListBoxAutomationPeer.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs index ba483b30027..9e24e508dbc 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -12,6 +12,13 @@ public class ListBoxAutomationPeer : SelectingItemsControlAutomationPeer public ListBoxAutomationPeer(ListBox owner) : base(owner) { + owner.ContainerPrepared += OnContainerPrepared; + } + + private void OnContainerPrepared(object? sender, ContainerPreparedEventArgs e) + { + if (e.Container.GetAutomationPeer() is ListItemAutomationPeer peer) + peer.Index = e.Index; } protected override void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) From 66f8c6d9b6c819dcad9d06c72d5683f9acf1baeb Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Mon, 23 Feb 2026 11:01:22 +1100 Subject: [PATCH 08/11] Prefer methods not prefixed with interface for consistency --- .../Automation/Peers/ListItemAutomationPeer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs index 0fe1ef29b5b..fe81637e75a 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs @@ -60,7 +60,7 @@ public void Select() } } - void ISelectionItemProvider.AddToSelection() + public void AddToSelection() { EnsureEnabled(); @@ -74,7 +74,7 @@ void ISelectionItemProvider.AddToSelection() } } - void ISelectionItemProvider.RemoveFromSelection() + public void RemoveFromSelection() { EnsureEnabled(); From bbecd8d7f07a7db9371b3301dc7c1d8dfd48fda9 Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Mon, 23 Feb 2026 11:03:12 +1100 Subject: [PATCH 09/11] Typo fix --- .../Automation/Peers/ListItemAutomationPeer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs index fe81637e75a..69d2fbf98c4 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs @@ -97,7 +97,7 @@ protected override AutomationControlType GetAutomationControlTypeCore() { var index = Index; - if (index != -1) + if (index == -1) return base.GetAutomationIdCore(); return $"{nameof(ListBoxItem)}: {index}"; From 6e928e00509fb7b2cd495c3b684c0cef75a9626f Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Mon, 23 Feb 2026 15:23:21 +1100 Subject: [PATCH 10/11] Implement multiple selection via VoiceOver Exposing -accessibilitySelectedChildren allows VoiceOver to enter a multiple selection mode. This is triggered the first time the user presses VO-Cmd-Return. In this mode, it stops calling -setAccessibilitySelected: with a parameter of YES each time the user lands on an item. Instead, it toggles the selection when the user presses VO-Cmd-Return. --- native/Avalonia.Native/src/OSX/automation.mm | 33 +++++++++++++++++-- .../Automation/Peers/ListBoxAutomationPeer.cs | 6 ++-- .../Peers/ListItemAutomationPeer.cs | 9 +++++ .../Provider/ISelectionItemProvider .cs | 4 +-- .../Automation/Provider/ISelectionProvider.cs | 4 +-- src/Avalonia.Native/AvnAutomationPeer.cs | 4 +++ src/Avalonia.Native/avn.idl | 5 ++- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index 34b8cad9516..ba19b58b41e 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -407,7 +407,7 @@ - (void)setAccessibilitySelected:(BOOL)accessibilitySelected if (!_peer->IsSelectionItemProvider()) return; if (accessibilitySelected) - _peer->SelectionItemProvider_Select(); + _peer->SelectionItemProvider_AddToSelection(); else _peer->SelectionItemProvider_RemoveFromSelection(); } @@ -420,6 +420,31 @@ - (BOOL)accessibilityPerformPick return YES; } +- (NSArray *)accessibilitySelectedChildren +{ + if (!_peer->IsSelectionProvider()) + return nil; + + auto selection = _peer->SelectionProvider_GetSelection(); + if (selection == nullptr) + return nil; + auto count = selection->GetCount(); + NSMutableArray* selectedElements = [[NSMutableArray alloc] initWithCapacity:count]; + + for (int i = 0; i < count; ++i) + { + IAvnAutomationPeer* selectedPeer; + + if (selection->Get(i, &selectedPeer) == S_OK) + { + id element = [AvnAccessibilityElement acquire:selectedPeer]; + [selectedElements addObject:element]; + } + } + + return selectedElements; +} + - (BOOL)isAccessibilitySelectorAllowed:(SEL)selector { if (selector == @selector(accessibilityPerformShowMenu)) @@ -441,7 +466,11 @@ - (BOOL)isAccessibilitySelectorAllowed:(SEL)selector { return _peer->IsRangeValueProvider(); } - + else if (selector == @selector(accessibilityPerformPick)) + { + return _peer->IsSelectionItemProvider(); + } + return [super isAccessibilitySelectorAllowed:selector]; } diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs index 9e24e508dbc..c25fe178038 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -72,25 +72,26 @@ internal class UnrealizedListItemAutomationPeer(ListBoxAutomationPeer owner, int public void Select() { EnsureEnabled(); + BringIntoView(); _listBox.SelectedIndex = index; - BringIntoView(); } public void AddToSelection() { EnsureEnabled(); + BringIntoView(); if (_listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) { selectionModel.Select(index); - BringIntoView(); } } public void RemoveFromSelection() { EnsureEnabled(); + BringIntoView(); if (_listBox.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) { @@ -134,7 +135,6 @@ protected override Rect GetBoundingRectangleCore() protected override void SetFocusCore() { - _listBox.SelectedIndex = index; BringIntoView(); } } diff --git a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs index 69d2fbf98c4..a92bad324d9 100644 --- a/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ListItemAutomationPeer.cs @@ -50,6 +50,7 @@ public ISelectionProvider? SelectionContainer public void Select() { EnsureEnabled(); + BringIntoView(); if (Owner.Parent is SelectingItemsControl parent) { @@ -63,6 +64,7 @@ public void Select() public void AddToSelection() { EnsureEnabled(); + BringIntoView(); if (Owner.Parent is ItemsControl parent && parent.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) @@ -77,6 +79,7 @@ public void AddToSelection() public void RemoveFromSelection() { EnsureEnabled(); + BringIntoView(); if (Owner.Parent is ItemsControl parent && parent.GetValue(ListBox.SelectionProperty) is ISelectionModel selectionModel) @@ -105,5 +108,11 @@ protected override AutomationControlType GetAutomationControlTypeCore() protected override bool IsContentElementCore() => true; protected override bool IsControlElementCore() => true; + + protected override void SetFocusCore() + { + base.SetFocusCore(); + BringIntoView(); + } } } diff --git a/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs b/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs index 041438bc237..2a77e2f0d21 100644 --- a/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs +++ b/src/Avalonia.Controls/Automation/Provider/ISelectionItemProvider .cs @@ -52,7 +52,7 @@ public interface ISelectionItemProvider /// /// /// macOS - /// No mapping. + /// NSAccessibilityProtocol.setAccessibilitySelected /// /// /// @@ -86,7 +86,7 @@ public interface ISelectionItemProvider /// /// /// macOS - /// NSAccessibilityProtocol.setAccessibilitySelected + /// NSAccessibilityProtocol.accessibilityPerformPick /// /// /// diff --git a/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs b/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs index 26b961314a3..fd3782a810f 100644 --- a/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs +++ b/src/Avalonia.Controls/Automation/Provider/ISelectionProvider.cs @@ -56,9 +56,7 @@ public interface ISelectionProvider /// /// /// macOS - /// - /// NSAccessibilityProtocol.accessibilitySelectedChildren (not implemented). - /// + /// NSAccessibilityProtocol.accessibilitySelectedChildren /// /// /// diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index 2bcf3cf9397..bd426a3a230 100644 --- a/src/Avalonia.Native/AvnAutomationPeer.cs +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -93,6 +93,7 @@ public IAvnAutomationPeer? RootPeer private IInvokeProvider InvokeProvider => GetProvider(); private IRangeValueProvider RangeValueProvider => GetProvider(); private IRootProvider RootProvider => GetProvider(); + private ISelectionProvider SelectionProvider => GetProvider(); private ISelectionItemProvider SelectionItemProvider => GetProvider(); private IToggleProvider ToggleProvider => GetProvider(); private IValueProvider ValueProvider => GetProvider(); @@ -173,6 +174,9 @@ public IAvnAutomationPeer? RootPeer public double RangeValueProvider_GetLargeChange() => RangeValueProvider.LargeChange; public void RangeValueProvider_SetValue(double value) => RangeValueProvider.SetValue(value); + public int IsSelectionProvider() => IsProvider(); + public IAvnAutomationPeerArray? SelectionProvider_GetSelection() => new AvnAutomationPeerArray(SelectionProvider.GetSelection()); + public int IsSelectionItemProvider() => IsProvider(); public int SelectionItemProvider_IsSelected() => SelectionItemProvider.IsSelected.AsComBool(); public void SelectionItemProvider_AddToSelection() => SelectionItemProvider.AddToSelection(); diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 99af1246dca..264abc6805f 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -1316,7 +1316,10 @@ interface IAvnAutomationPeer : IUnknown double RangeValueProvider_GetSmallChange(); double RangeValueProvider_GetLargeChange(); void RangeValueProvider_SetValue(double value); - + + bool IsSelectionProvider(); + IAvnAutomationPeerArray* SelectionProvider_GetSelection(); + bool IsSelectionItemProvider(); bool SelectionItemProvider_IsSelected(); void SelectionItemProvider_AddToSelection(); From f22d82363c29f21c834d56329d996d22800785bc Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Mon, 23 Feb 2026 15:27:53 +1100 Subject: [PATCH 11/11] Silence warnings about missing enum cases --- native/Avalonia.Native/src/OSX/automation.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index ba19b58b41e..1842dceeb0c 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -144,6 +144,7 @@ - (NSAccessibilitySubrole)accessibilitySubrole auto landmarkType = _peer->GetLandmarkType(); switch (landmarkType) { + case LandmarkNone: break; case LandmarkBanner: return @"AXLandmarkBanner"; case LandmarkComplementary: return @"AXLandmarkComplementary"; case LandmarkContentInfo: return @"AXLandmarkContentInfo"; @@ -161,6 +162,7 @@ - (NSString *)accessibilityRoleDescription { auto landmarkType = _peer->GetLandmarkType(); switch (landmarkType) { + case LandmarkNone: break; case LandmarkBanner: return @"banner"; case LandmarkComplementary: return @"complementary"; case LandmarkContentInfo: return @"content";