diff --git a/packages/react-native/Libraries/Components/View/ViewAccessibility.js b/packages/react-native/Libraries/Components/View/ViewAccessibility.js index 09d0b4c1977224..409c3662bf6598 100644 --- a/packages/react-native/Libraries/Components/View/ViewAccessibility.js +++ b/packages/react-native/Libraries/Components/View/ViewAccessibility.js @@ -320,6 +320,18 @@ export type AccessibilityPropsIOS = $ReadOnly<{ */ accessibilityElementsHidden?: ?boolean, + /** + * When true, indicates that this view should act as an accessibility + * container, allowing nested interactive elements to be individually + * accessible. Use this when you have nested Pressables or other + * interactive components that need to be separately accessible. + * + * When enabled, VoiceOver will be able to navigate to its accessible children. + * + * @platform ios + */ + accessibilityContainer?: ?boolean, + /** * Indicates to the accessibility services that the UI component is in * a specific language. The provided string should be formatted following diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index 5c96ddeabeff42..33149784560e21 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -193,6 +193,7 @@ const validAttributesForNonEventProps = { accessibilityValue: true, accessibilityViewIsModal: true, accessibilityElementsHidden: true, + accessibilityContainer: true, accessibilityIgnoresInvertColors: true, accessibilityShowsLargeContentViewer: true, accessibilityLargeContentTitle: true, diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index f693d74551a552..00c4668f1717c1 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -53,6 +53,8 @@ @implementation RCTViewComponentView { BOOL _useCustomContainerView; NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; + BOOL _isAccessibilityContainer; + NSArray *_accessibilityElements; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -343,6 +345,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & self.nativeId = RCTNSStringFromStringNilIfEmpty(newViewProps.nativeId); } + // `accessibilityContainer` - needs to be handled before `accessible` so the setter override works + if (oldViewProps.accessibilityContainer != newViewProps.accessibilityContainer) { + _isAccessibilityContainer = newViewProps.accessibilityContainer; + // Clear cached accessibility elements when this changes + _accessibilityElements = nil; + } + // `accessible` if (oldViewProps.accessible != newViewProps.accessible) { self.accessibilityElement.isAccessibilityElement = newViewProps.accessible; @@ -1353,6 +1362,12 @@ - (BOOL)wantsToCooptLabel - (BOOL)isAccessibilityElement { + // If this view is an accessibility container, it should not itself be an accessibility element + // This allows the accessibilityElements array to be used instead + if (_isAccessibilityContainer) { + return NO; + } + if (self.contentView != nil) { return self.contentView.isAccessibilityElement; } @@ -1360,6 +1375,16 @@ - (BOOL)isAccessibilityElement return [super isAccessibilityElement]; } +- (void)setIsAccessibilityElement:(BOOL)isAccessibilityElement +{ + // If this is an accessibility container, prevent setting isAccessibilityElement to YES + if (_isAccessibilityContainer) { + // Don't call super - we want to keep it as NO + return; + } + [super setIsAccessibilityElement:isAccessibilityElement]; +} + - (NSString *)accessibilityValue { const auto &props = static_cast(*_props); @@ -1637,6 +1662,77 @@ - (BOOL)resignFirstResponder return YES; } +#pragma mark - UIAccessibilityContainer + +- (BOOL)isAccessibilityContainer +{ + return _isAccessibilityContainer; +} + +- (NSArray *)accessibilityElements +{ + if (!_isAccessibilityContainer) { + return [super accessibilityElements]; + } + [self updateAccessibilityElementsIfNeeded]; + return _accessibilityElements; +} + +- (void)setAccessibilityElements:(NSArray *)accessibilityElements +{ + // Allow setting accessibilityElements, but if we're managing it ourselves, ignore + if (!_isAccessibilityContainer) { + [super setAccessibilityElements:accessibilityElements]; + } +} + + +- (void)updateAccessibilityElementsIfNeeded +{ + if (_accessibilityElements != nil) { + return; + } + + NSMutableArray *elements = [NSMutableArray new]; + + // First, add an accessibility element for the container itself + // This allows VoiceOver to access the container's action (e.g., an outer Pressable's onPress) + if (super.accessibilityLabel || super.accessibilityTraits != UIAccessibilityTraitNone) { + UIAccessibilityElement *containerElement = [[UIAccessibilityElement alloc] initWithAccessibilityContainer:self]; + containerElement.accessibilityLabel = super.accessibilityLabel; + containerElement.accessibilityHint = super.accessibilityHint; + containerElement.accessibilityTraits = super.accessibilityTraits; + containerElement.accessibilityFrame = self.accessibilityFrame; + containerElement.accessibilityFrameInContainerSpace = self.bounds; + [elements addObject:containerElement]; + } + + // Then collect all accessible child elements + UIView *container = self.contentView ?: self; + + for (UIView *subview in container.subviews) { + if (subview.isAccessibilityElement) { + [elements addObject:subview]; + } else if ([subview respondsToSelector:@selector(accessibilityElements)] && + [subview accessibilityElements]) { + // If the subview has its own accessibilityElements array, add those + NSArray *subElements = [subview accessibilityElements]; + [elements addObjectsFromArray:subElements]; + } else if ([subview respondsToSelector:@selector(accessibilityElementCount)] && + [subview accessibilityElementCount] > 0) { + // Recursively collect accessibility elements from child containers + NSInteger count = [subview accessibilityElementCount]; + for (NSInteger i = 0; i < count; i++) { + id element = [subview accessibilityElementAtIndex:i]; + if (element) { + [elements addObject:element]; + } + } + } + } + _accessibilityElements = [elements copy]; +} + @end #ifdef __cplusplus diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp index cc39827f8effcc..d7da5c94328752 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp @@ -163,6 +163,15 @@ AccessibilityProps::AccessibilityProps( "accessibilityRespondsToUserInteraction", sourceProps.accessibilityRespondsToUserInteraction, true)), + accessibilityContainer( + ReactNativeFeatureFlags::enableCppPropsIteratorSetter() + ? sourceProps.accessibilityContainer + : convertRawProp( + context, + rawProps, + "accessibilityContainer", + sourceProps.accessibilityContainer, + false)), onAccessibilityTap( ReactNativeFeatureFlags::enableCppPropsIteratorSetter() ? sourceProps.onAccessibilityTap @@ -280,6 +289,7 @@ void AccessibilityProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(accessibilityElementsHidden); RAW_SET_PROP_SWITCH_CASE_BASIC(accessibilityIgnoresInvertColors); RAW_SET_PROP_SWITCH_CASE_BASIC(accessibilityRespondsToUserInteraction); + RAW_SET_PROP_SWITCH_CASE_BASIC(accessibilityContainer); RAW_SET_PROP_SWITCH_CASE_BASIC(onAccessibilityTap); RAW_SET_PROP_SWITCH_CASE_BASIC(onAccessibilityMagicTap); RAW_SET_PROP_SWITCH_CASE_BASIC(onAccessibilityEscape); @@ -327,6 +337,10 @@ SharedDebugStringConvertibleList AccessibilityProps::getDebugProps() const { "accessibilityElementsHidden", accessibilityElementsHidden, defaultProps.accessibilityElementsHidden), + debugStringConvertibleItem( + "accessibilityContainer", + accessibilityContainer, + defaultProps.accessibilityContainer), debugStringConvertibleItem( "accessibilityHint", accessibilityHint, diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h index 7c798c1cf3e036..c6d63c59802c85 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.h @@ -53,6 +53,7 @@ class AccessibilityProps { // C++ because if not, it will default to false before render which prevents // the view from being updated with the correct value. bool accessibilityRespondsToUserInteraction{true}; + bool accessibilityContainer{false}; bool onAccessibilityTap{}; bool onAccessibilityMagicTap{}; bool onAccessibilityEscape{}; diff --git a/packages/rn-tester/js/examples/Pressable/PressableExample.js b/packages/rn-tester/js/examples/Pressable/PressableExample.js index 648fe4d5e6b49a..55d7da245c153f 100644 --- a/packages/rn-tester/js/examples/Pressable/PressableExample.js +++ b/packages/rn-tester/js/examples/Pressable/PressableExample.js @@ -605,6 +605,52 @@ const examples = [ ); }, }, + { + title: 'Nested Pressables with Accessibility Container (iOS)', + description: ( + 'Demonstrates accessibilityContainer prop for nested interactive elements': string + ), + render: function NestedPressablesWithAccessibilityContainer(): React.Node { + const [outerPressCount, setOuterPressCount] = useState(0); + const [innerPressCount, setInnerPressCount] = useState(0); + + return ( + + + + Outer: {outerPressCount} | Inner: {innerPressCount} + + + + setOuterPressCount(outerPressCount + 1)} + style={{ + backgroundColor: '#f9c2ff', + padding: 16, + borderRadius: 8, + }}> + Outer Pressable + setInnerPressCount(innerPressCount + 1)} + style={{ + backgroundColor: '#61dafb', + padding: 12, + marginTop: 8, + borderRadius: 6, + }}> + Inner Pressable + + + + + ); + }, + }, ...PressableExampleFbInternal.examples, ];