From 767080cc3ca48dbf2bb595d5e972b3cfbe386044 Mon Sep 17 00:00:00 2001 From: Anukrati Agrawal Date: Wed, 16 Sep 2026 16:07:58 -0700 Subject: [PATCH 1/2] Raise UIA events for selection changes Notify UI Automation clients when accessibilityState.selected changes and raise SelectionItem events according to the container's selection mode and selected item count. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f63ea4f-a16b-4fa3-b141-785aec89b5e4 --- ...-22b1e2a3-5ecc-4c5c-b127-e625595c8026.json | 7 + ...osoft.ReactNative.IntegrationTests.vcxproj | 1 + ...actNative.IntegrationTests.vcxproj.filters | 1 + .../UiaHelpersTests.cpp | 122 ++++++++++++++++++ .../CompositionDynamicAutomationProvider.cpp | 74 ++++------- .../CompositionDynamicAutomationProvider.h | 3 - .../CompositionViewComponentView.cpp | 31 +++-- .../SelectionItemAutomationEvent.h | 63 +++++++++ .../Fabric/Composition/UiaHelpers.cpp | 88 ++++++++----- .../Fabric/Composition/UiaHelpers.h | 6 +- 10 files changed, 297 insertions(+), 99 deletions(-) create mode 100644 change/react-native-windows-22b1e2a3-5ecc-4c5c-b127-e625595c8026.json create mode 100644 vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp create mode 100644 vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h diff --git a/change/react-native-windows-22b1e2a3-5ecc-4c5c-b127-e625595c8026.json b/change/react-native-windows-22b1e2a3-5ecc-4c5c-b127-e625595c8026.json new file mode 100644 index 00000000000..60ac025bc54 --- /dev/null +++ b/change/react-native-windows-22b1e2a3-5ecc-4c5c-b127-e625595c8026.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Raise UI Automation selection events when accessibilityState.selected changes.", + "packageName": "react-native-windows", + "email": "anuagra@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj b/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj index 405d35af50f..3f0bef8e2c4 100644 --- a/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj +++ b/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj @@ -144,6 +144,7 @@ + Create diff --git a/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj.filters b/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj.filters index ad4030aaada..d5f69857d70 100644 --- a/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj.filters +++ b/vnext/Microsoft.ReactNative.IntegrationTests/Microsoft.ReactNative.IntegrationTests.vcxproj.filters @@ -9,6 +9,7 @@ + Utilities diff --git a/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp b/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp new file mode 100644 index 00000000000..99231d28f2a --- /dev/null +++ b/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "pch.h" + +#include "../Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h" + +namespace ReactNativeIntegrationTests { + +namespace { + +struct SelectionNode { + bool mounted{true}; + bool selectionContainer{false}; + std::optional selected; + std::vector children; +}; + +std::vector GetSelectedItems(SelectionNode &selectionContainer) { + return winrt::Microsoft::ReactNative::implementation::GetSelectedItemsInSelectionContainer( + &selectionContainer, + [](const auto node) -> const auto & { return node->children; }, + [](const auto node) { return node->mounted; }, + [](const auto node) { return node->selectionContainer; }, + [](const auto node) { return node->selected.value_or(false); }); +} + +} // namespace + +TEST_CLASS (UiaHelpersTests) { + TEST_METHOD(UnmountedSelectionItemStateChangesAreSuppressed) { + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged( + false, std::nullopt, true)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(false, false, true)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(false, true, false)); + } + + TEST_METHOD(MountedSelectionItemStateChangesUseMissingAsFalse) { + TestCheck(winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(true, false, true)); + TestCheck(winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(true, true, false)); + TestCheck( + winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(true, std::nullopt, true)); + TestCheck( + winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(true, true, std::nullopt)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged(true, false, false)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged( + true, std::nullopt, false)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged( + true, false, std::nullopt)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged( + true, std::nullopt, std::nullopt)); + } + + TEST_METHOD(SelectedItemsComeFromMountedHierarchyAndStopAtNestedContainers) { + SelectionNode directSelected; + directSelected.selected = true; + SelectionNode directUnselected; + directUnselected.selected = false; + SelectionNode unspecifiedSelection; + SelectionNode wrappedSelected; + wrappedSelected.selected = true; + SelectionNode unmountedSelected; + unmountedSelected.mounted = false; + unmountedSelected.selected = true; + SelectionNode nestedSelected; + nestedSelected.selected = true; + SelectionNode nestedContainer; + nestedContainer.selectionContainer = true; + nestedContainer.selected = true; + nestedContainer.children = {&nestedSelected}; + SelectionNode wrapper; + wrapper.children = {&wrappedSelected, &unmountedSelected, &nestedContainer}; + SelectionNode root; + root.selectionContainer = true; + root.children = {&directSelected, &directUnselected, &unspecifiedSelection, &wrapper}; + + auto selectedItems = GetSelectedItems(root); + + TestCheckEqual(size_t{3}, selectedItems.size()); + TestCheckEqual(&directSelected, selectedItems[0]); + TestCheckEqual(&wrappedSelected, selectedItems[1]); + TestCheckEqual(&nestedContainer, selectedItems[2]); + } + + TEST_METHOD(SelectedItemInSingleSelectionContainerRaisesElementSelected) { + TestCheckEqual( + UIA_SelectionItem_ElementSelectedEventId, + winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(true, false, 1)); + } + + TEST_METHOD(FirstSelectedItemInMultiSelectionContainerRaisesElementSelected) { + TestCheckEqual( + UIA_SelectionItem_ElementSelectedEventId, + winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(true, true, 1)); + } + + TEST_METHOD(AdditionalSelectedItemInMultiSelectionContainerRaisesElementAdded) { + TestCheckEqual( + UIA_SelectionItem_ElementAddedToSelectionEventId, + winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(true, true, 2)); + } + + TEST_METHOD(RemovedItemRaisesElementRemoved) { + TestCheckEqual( + UIA_SelectionItem_ElementRemovedFromSelectionEventId, + winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(false, true, 2)); + } + + TEST_METHOD(RemovedItemInSingleSelectionContainerRaisesElementRemoved) { + TestCheckEqual( + UIA_SelectionItem_ElementRemovedFromSelectionEventId, + winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(false, false, 1)); + } + + TEST_METHOD(RemovalLeavingOneSelectedItemRaisesElementSelected) { + TestCheckEqual( + UIA_SelectionItem_ElementSelectedEventId, + winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(false, true, 1)); + } +}; + +} // namespace ReactNativeIntegrationTests diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.cpp index a1d7ba41d2f..6d266d02ad6 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.cpp @@ -32,21 +32,7 @@ bool IsHiddenByParent(const winrt::Microsoft::ReactNative::ComponentView &view) CompositionDynamicAutomationProvider::CompositionDynamicAutomationProvider( const winrt::Microsoft::ReactNative::Composition::ComponentView &componentView) noexcept - : m_view{componentView} { - auto strongView = m_view.view(); - - if (!strongView) - return; - - auto props = std::static_pointer_cast( - winrt::get_self(strongView)->props()); - if (!props) - return; - - if (props->accessibilityState.has_value() && props->accessibilityState->selected.has_value()) { - AddSelectionItemsToContainer(this); - } -} + : m_view{componentView} {} CompositionDynamicAutomationProvider::CompositionDynamicAutomationProvider( const winrt::Microsoft::ReactNative::Composition::ComponentView &componentView, @@ -951,53 +937,49 @@ HRESULT __stdcall CompositionDynamicAutomationProvider::get_IsSelectionRequired( } HRESULT __stdcall CompositionDynamicAutomationProvider::GetSelection(SAFEARRAY **pRetVal) { + if (pRetVal == nullptr) + return E_POINTER; + + *pRetVal = nullptr; + auto strongView = m_view.view(); if (!strongView) return UIA_E_ELEMENTNOTAVAILABLE; - std::vector selectedItems; - for (size_t i = 0; i < m_selectionItems.size(); i++) { - auto selectionItem = m_selectionItems.at(i); - - winrt::com_ptr unkSelectionItemProvider; - auto hr = selectionItem->GetPatternProvider(UIA_SelectionItemPatternId, unkSelectionItemProvider.put()); - if (FAILED(hr)) - return hr; - - auto selectionItemProvider = unkSelectionItemProvider.try_as(); - if (!selectionItemProvider) - return E_FAIL; - - BOOL selected; - hr = selectionItemProvider->get_IsSelected(&selected); - if (hr == S_OK && selected) { - selectedItems.push_back(int(i)); - } - } + auto selectedItems = GetSelectedItemsInSelectionContainer(strongView); *pRetVal = SafeArrayCreateVector(VT_UNKNOWN, 0, ULONG(selectedItems.size())); if (*pRetVal == nullptr) return E_OUTOFMEMORY; for (size_t i = 0; i < selectedItems.size(); i++) { + auto selectionItem = + selectedItems[i].try_as(); + if (!selectionItem) { + SafeArrayDestroy(*pRetVal); + *pRetVal = nullptr; + return E_FAIL; + } + + auto selectionItemProvider = selectionItem->EnsureUiaProvider().try_as(); + if (!selectionItemProvider) { + SafeArrayDestroy(*pRetVal); + *pRetVal = nullptr; + return E_FAIL; + } + auto pos = static_cast(i); - SafeArrayPutElement(*pRetVal, &pos, m_selectionItems.at(selectedItems.at(i)).get()); + auto hr = SafeArrayPutElement(*pRetVal, &pos, selectionItemProvider.get()); + if (FAILED(hr)) { + SafeArrayDestroy(*pRetVal); + *pRetVal = nullptr; + return hr; + } } return S_OK; } -void CompositionDynamicAutomationProvider::AddToSelectionItems(winrt::com_ptr &item) { - if (std::find(m_selectionItems.begin(), m_selectionItems.end(), item) != m_selectionItems.end()) { - return; - } - m_selectionItems.push_back(item); -} - -void CompositionDynamicAutomationProvider::RemoveFromSelectionItems(winrt::com_ptr &item) { - std::erase(m_selectionItems, item); -} - HRESULT __stdcall CompositionDynamicAutomationProvider::AddToSelection() { auto strongView = m_view.view(); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.h b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.h index 7a5b8d686ac..37bcd0fe8dd 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionDynamicAutomationProvider.h @@ -95,8 +95,6 @@ class CompositionDynamicAutomationProvider : public winrt::implements< virtual HRESULT __stdcall RemoveFromSelection() override; virtual HRESULT __stdcall Select() override; - void AddToSelectionItems(winrt::com_ptr &item); - void RemoveFromSelectionItems(winrt::com_ptr &item); winrt::Microsoft::ReactNative::ComponentView GetSelectionContainer() noexcept; void SetChildSiteLink(winrt::Microsoft::UI::Content::ChildSiteLink childSiteLink) { @@ -111,7 +109,6 @@ class CompositionDynamicAutomationProvider : public winrt::implements< ::Microsoft::ReactNative::ReactTaggedView m_view; winrt::com_ptr m_textProvider; winrt::com_ptr m_annotationProvider; - std::vector> m_selectionItems; // Non-null when this UIA node is the peer of a ContentIslandComponentView. winrt::Microsoft::UI::Content::ChildSiteLink m_childSiteLink{nullptr}; }; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp index 727a3cb4fa6..a32b0d02489 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp @@ -23,6 +23,7 @@ #include "CompositionDynamicAutomationProvider.h" #include "CompositionHelpers.h" #include "RootComponentView.h" +#include "SelectionItemAutomationEvent.h" #include "Theme.h" #include "TooltipService.h" #include "UiaHelpers.h" @@ -963,19 +964,6 @@ void ComponentView::updateAccessibilityProps( static_cast(winrt::Microsoft::ReactNative::implementation::GetExpandCollapseState(oldExpanded)), static_cast(winrt::Microsoft::ReactNative::implementation::GetExpandCollapseState(newExpanded))); } - - if ((oldViewProps.accessibilityState.has_value() && oldViewProps.accessibilityState->selected.has_value()) != - ((newViewProps.accessibilityState.has_value() && newViewProps.accessibilityState->selected.has_value()))) { - EnsureUiaProvider(); - if (m_innerAutomationProvider) { - if ((newViewProps.accessibilityState.has_value() && newViewProps.accessibilityState->selected.has_value())) { - winrt::Microsoft::ReactNative::implementation::AddSelectionItemsToContainer(m_innerAutomationProvider.get()); - } else { - winrt::Microsoft::ReactNative::implementation::RemoveSelectionItemsFromContainer( - m_innerAutomationProvider.get()); - } - } - } } std::optional ComponentView::getAccessiblityValue() noexcept { @@ -1271,6 +1259,10 @@ void ViewComponentView::updateProps( facebook::react::Props::Shared const &oldProps) noexcept { const auto &oldViewProps = *std::static_pointer_cast(oldProps ? oldProps : m_props); const auto &newViewProps = *std::static_pointer_cast(props); + const auto oldSelected = + oldViewProps.accessibilityState.has_value() ? oldViewProps.accessibilityState->selected : std::nullopt; + const auto newSelected = + newViewProps.accessibilityState.has_value() ? newViewProps.accessibilityState->selected : std::nullopt; ensureVisual(); if (oldViewProps.opacity != newViewProps.opacity) { @@ -1286,6 +1278,19 @@ void ViewComponentView::updateProps( base_type::updateProps(props, oldProps); m_props = std::static_pointer_cast(props); + + if (UiaClientsAreListening() && + winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemStateChanged( + isMounted(), oldSelected, newSelected)) { + auto provider = EnsureUiaProvider(); + winrt::Microsoft::ReactNative::implementation::UpdateUiaProperty( + provider, UIA_SelectionItemIsSelectedPropertyId, oldSelected.value_or(false), newSelected.value_or(false)); + + if (m_innerAutomationProvider) { + winrt::Microsoft::ReactNative::implementation::RaiseSelectionItemAutomationEvent( + m_innerAutomationProvider.get(), newSelected.value_or(false)); + } + } } const winrt::Microsoft::ReactNative::IComponentProps ViewComponentView::userProps( diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h b/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h new file mode 100644 index 00000000000..ed5ef63fe1c --- /dev/null +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#pragma once + +#include +#include +#include +#include +#include + +namespace winrt::Microsoft::ReactNative::implementation { + +inline bool ShouldRaiseSelectionItemStateChanged( + bool isMounted, + const std::optional &oldSelected, + const std::optional &newSelected) noexcept { + return isMounted && oldSelected.value_or(false) != newSelected.value_or(false); +} + +template +std::vector GetSelectedItemsInSelectionContainer( + const Node &selectionContainer, + GetChildren &&getChildren, + IsMounted &&isMounted, + IsSelectionContainer &&isSelectionContainer, + IsSelected &&isSelected) { + std::vector selectedItems; + std::function visitChildren = [&](const Node &parent) { + for (const auto &child : getChildren(parent)) { + if (!isMounted(child)) { + continue; + } + + if (isSelected(child)) { + selectedItems.push_back(child); + } + + if (!isSelectionContainer(child)) { + visitChildren(child); + } + } + }; + + visitChildren(selectionContainer); + return selectedItems; +} + +inline EVENTID +GetSelectionItemAutomationEventId(bool isSelected, bool canSelectMultiple, size_t selectedItemCount) noexcept { + if (isSelected && (!canSelectMultiple || selectedItemCount == 1)) { + return UIA_SelectionItem_ElementSelectedEventId; + } + + if (isSelected) { + return UIA_SelectionItem_ElementAddedToSelectionEventId; + } + + return canSelectMultiple && selectedItemCount == 1 ? UIA_SelectionItem_ElementSelectedEventId + : UIA_SelectionItem_ElementRemovedFromSelectionEventId; +} + +} // namespace winrt::Microsoft::ReactNative::implementation diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp index cd2084a18ed..7db0ed35fb2 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp @@ -5,6 +5,7 @@ #include #include "CompositionRootAutomationProvider.h" #include "RootComponentView.h" +#include "SelectionItemAutomationEvent.h" namespace winrt::Microsoft::ReactNative::implementation { @@ -519,46 +520,63 @@ ExpandCollapseState GetExpandCollapseState(const bool &expanded) noexcept { } } -void AddSelectionItemsToContainer(CompositionDynamicAutomationProvider *provider) noexcept { - auto selectionContainerView = provider->GetSelectionContainer(); - if (!selectionContainerView) - return; - - auto selectionContainerCompView = - selectionContainerView.try_as(); - if (!selectionContainerCompView) - return; - - selectionContainerCompView->EnsureUiaProvider(); - - if (!selectionContainerCompView->InnerAutomationProvider()) - return; - - auto simpleProvider = static_cast(provider); - winrt::com_ptr simpleProviderPtr; - simpleProviderPtr.copy_from(simpleProvider); - selectionContainerCompView->InnerAutomationProvider()->AddToSelectionItems(simpleProviderPtr); +std::vector GetSelectedItemsInSelectionContainer( + const winrt::Microsoft::ReactNative::ComponentView &selectionContainer) noexcept { + return GetSelectedItemsInSelectionContainer( + selectionContainer, + [](const auto &view) { return view.Children(); }, + [](const auto &view) { + return winrt::get_self(view)->isMounted(); + }, + [](const auto &view) { + auto props = std::static_pointer_cast( + winrt::get_self(view)->props()); + return props && props->accessibilityState.has_value() && + props->accessibilityState->multiselectable.has_value() && props->accessibilityState->required.has_value(); + }, + [](const auto &view) { + auto props = std::static_pointer_cast( + winrt::get_self(view)->props()); + return props && props->accessibilityState.has_value() && props->accessibilityState->selected.value_or(false); + }); } -void RemoveSelectionItemsFromContainer(CompositionDynamicAutomationProvider *provider) noexcept { - auto selectionContainerView = provider->GetSelectionContainer(); - if (!selectionContainerView) - return; +void RaiseSelectionItemAutomationEvent(CompositionDynamicAutomationProvider *provider, bool isSelected) noexcept { + BOOL canSelectMultiple = false; + size_t selectedItemCount = isSelected ? 1 : 0; + winrt::com_ptr eventProvider; + eventProvider.copy_from(static_cast(provider)); - auto selectionContainerCompView = - selectionContainerView.try_as(); - if (!selectionContainerCompView) - return; - - selectionContainerCompView->EnsureUiaProvider(); + auto selectionContainerView = provider->GetSelectionContainer(); + if (selectionContainerView) { + auto selectionContainerCompView = + selectionContainerView.try_as(); + if (selectionContainerCompView) { + auto props = selectionContainerCompView->viewProps(); + canSelectMultiple = + props->accessibilityState.has_value() && props->accessibilityState->multiselectable.value_or(false); + + auto selectedItems = GetSelectedItemsInSelectionContainer(selectionContainerView); + selectedItemCount = selectedItems.size(); + if (!isSelected && canSelectMultiple && selectedItemCount == 1) { + auto remainingSelectedItem = + selectedItems[0].try_as(); + if (!remainingSelectedItem) { + return; + } - if (!selectionContainerCompView->InnerAutomationProvider()) - return; + auto remainingSelectedItemProvider = + remainingSelectedItem->EnsureUiaProvider().try_as(); + if (!remainingSelectedItemProvider) { + return; + } + eventProvider = std::move(remainingSelectedItemProvider); + } + } + } - auto simpleProvider = static_cast(provider); - winrt::com_ptr simpleProviderPtr; - simpleProviderPtr.copy_from(simpleProvider); - selectionContainerCompView->InnerAutomationProvider()->RemoveFromSelectionItems(simpleProviderPtr); + UiaRaiseAutomationEvent( + eventProvider.get(), GetSelectionItemAutomationEventId(isSelected, canSelectMultiple, selectedItemCount)); } ToggleState GetToggleState(const std::optional &state) noexcept { diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h index 3efd94da399..039c8c1c5d8 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h @@ -5,6 +5,7 @@ #include #include #include +#include namespace Microsoft::ReactNative { struct winrt::Microsoft::ReactNative::implementation::ComponentView; @@ -75,9 +76,10 @@ void DispatchAccessibilityAction(::Microsoft::ReactNative::ReactTaggedView &view ExpandCollapseState GetExpandCollapseState(const bool &expanded) noexcept; -void AddSelectionItemsToContainer(CompositionDynamicAutomationProvider *provider) noexcept; +std::vector GetSelectedItemsInSelectionContainer( + const winrt::Microsoft::ReactNative::ComponentView &selectionContainer) noexcept; -void RemoveSelectionItemsFromContainer(CompositionDynamicAutomationProvider *provider) noexcept; +void RaiseSelectionItemAutomationEvent(CompositionDynamicAutomationProvider *provider, bool isSelected) noexcept; ToggleState GetToggleState(const std::optional &state) noexcept; From 3811196014e47476834e6039d9dd52af5209d6a8 Mon Sep 17 00:00:00 2001 From: Anukrati Agrawal Date: Thu, 17 Sep 2026 12:22:36 -0700 Subject: [PATCH 2/2] Announce focused single-selection changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f63ea4f-a16b-4fa3-b141-785aec89b5e4 --- .../UiaHelpersTests.cpp | 10 ++++++++++ .../Composition/CompositionViewComponentView.cpp | 3 ++- .../Composition/SelectionItemAutomationEvent.h | 5 +++++ .../Fabric/Composition/UiaHelpers.cpp | 12 +++++++++++- .../Fabric/Composition/UiaHelpers.h | 5 ++++- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp b/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp index 99231d28f2a..caad2d7b9c5 100644 --- a/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp +++ b/vnext/Microsoft.ReactNative.IntegrationTests/UiaHelpersTests.cpp @@ -117,6 +117,16 @@ TEST_CLASS (UiaHelpersTests) { UIA_SelectionItem_ElementSelectedEventId, winrt::Microsoft::ReactNative::implementation::GetSelectionItemAutomationEventId(false, true, 1)); } + + TEST_METHOD(FocusedSelectedItemInSingleSelectionContainerRaisesFocusEvent) { + TestCheck(winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemFocusEvent(true, false, true)); + } + + TEST_METHOD(SelectionItemFocusEventIsLimitedToFocusedSingleSelection) { + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemFocusEvent(false, false, true)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemFocusEvent(true, true, true)); + TestCheck(!winrt::Microsoft::ReactNative::implementation::ShouldRaiseSelectionItemFocusEvent(true, false, false)); + } }; } // namespace ReactNativeIntegrationTests diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp index a32b0d02489..4b0ce41a8eb 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp @@ -1287,8 +1287,9 @@ void ViewComponentView::updateProps( provider, UIA_SelectionItemIsSelectedPropertyId, oldSelected.value_or(false), newSelected.value_or(false)); if (m_innerAutomationProvider) { + auto root = rootComponentView(); winrt::Microsoft::ReactNative::implementation::RaiseSelectionItemAutomationEvent( - m_innerAutomationProvider.get(), newSelected.value_or(false)); + m_innerAutomationProvider.get(), newSelected.value_or(false), root && root->GetFocusedComponent() == *this); } } } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h b/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h index ed5ef63fe1c..50631a4c250 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/SelectionItemAutomationEvent.h @@ -60,4 +60,9 @@ GetSelectionItemAutomationEventId(bool isSelected, bool canSelectMultiple, size_ : UIA_SelectionItem_ElementRemovedFromSelectionEventId; } +inline bool +ShouldRaiseSelectionItemFocusEvent(bool isSelected, bool canSelectMultiple, bool hasKeyboardFocus) noexcept { + return isSelected && !canSelectMultiple && hasKeyboardFocus; +} + } // namespace winrt::Microsoft::ReactNative::implementation diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp index 7db0ed35fb2..d7b773a277e 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.cpp @@ -541,7 +541,10 @@ std::vector GetSelectedItemsInSele }); } -void RaiseSelectionItemAutomationEvent(CompositionDynamicAutomationProvider *provider, bool isSelected) noexcept { +void RaiseSelectionItemAutomationEvent( + CompositionDynamicAutomationProvider *provider, + bool isSelected, + bool hasKeyboardFocus) noexcept { BOOL canSelectMultiple = false; size_t selectedItemCount = isSelected ? 1 : 0; winrt::com_ptr eventProvider; @@ -577,6 +580,13 @@ void RaiseSelectionItemAutomationEvent(CompositionDynamicAutomationProvider *pro UiaRaiseAutomationEvent( eventProvider.get(), GetSelectionItemAutomationEventId(isSelected, canSelectMultiple, selectedItemCount)); + + // Windows screen readers reliably announce a selection change on an already-focused item + // when the focused item raises a focus event. Keep the SelectionItem event above for UIA + // clients that consume the standard selection contract. + if (ShouldRaiseSelectionItemFocusEvent(isSelected, canSelectMultiple, hasKeyboardFocus)) { + UiaRaiseAutomationEvent(eventProvider.get(), UIA_AutomationFocusChangedEventId); + } } ToggleState GetToggleState(const std::optional &state) noexcept { diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h index 039c8c1c5d8..50a4d4e9b33 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/UiaHelpers.h @@ -79,7 +79,10 @@ ExpandCollapseState GetExpandCollapseState(const bool &expanded) noexcept; std::vector GetSelectedItemsInSelectionContainer( const winrt::Microsoft::ReactNative::ComponentView &selectionContainer) noexcept; -void RaiseSelectionItemAutomationEvent(CompositionDynamicAutomationProvider *provider, bool isSelected) noexcept; +void RaiseSelectionItemAutomationEvent( + CompositionDynamicAutomationProvider *provider, + bool isSelected, + bool hasKeyboardFocus) noexcept; ToggleState GetToggleState(const std::optional &state) noexcept;