From 68644c74c3f9fd7e94f352713d8ee06914d57d11 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 21 Oct 2026 16:36:45 -0700 Subject: [PATCH 1/6] fix(macos): convert each array element with the ARIA role table +[RCTConvert accessibilityRoleFromTraits:useAriaMappings:] passed the whole array, rather than the current element, to the ARIA lookup, so array input never matched an ARIA role. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react-native/React/Base/RCTConvert.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 51287bfe5477..65e469c99106 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -1689,7 +1689,7 @@ + (NSString *)accessibilityRoleFromTraits:(id)json useAriaMappings:(BOOL)useAria return useAriaMappings ? [RCTConvert accessibilityRoleFromAriaRole:json] : [RCTConvert accessibilityRoleFromTrait:json]; } else if ([json isKindOfClass:[NSArray class]]) { for (NSString *trait in json) { - NSString *accessibilityRole = useAriaMappings ? [RCTConvert accessibilityRoleFromAriaRole:json] : [RCTConvert accessibilityRoleFromTrait:trait]; + NSString *accessibilityRole = useAriaMappings ? [RCTConvert accessibilityRoleFromAriaRole:trait] : [RCTConvert accessibilityRoleFromTrait:trait]; if (![accessibilityRole isEqualToString:NSAccessibilityUnknownRole]) { return accessibilityRole; } From 533d408e74ed2f62a279b89d6fbf9501eae8fa21 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 21 Oct 2026 16:36:53 -0700 Subject: [PATCH 2/6] fix(macos): derive Fabric accessibility role from role and accessibilityRole On macOS, RCTViewComponentView mapped only accessibilityTraits to a role. The C++ props convert just a few role strings to traits, so roles such as menuitem, option, tab, combobox and textbox reported AXUnknown. The role was also set on the component view rather than its accessibility element, so the field behind a TextInput never received it. Resolve the role the way Paper does: role with the ARIA table, then accessibilityRole with the legacy table, then the traits, and otherwise the element's default role. Write it to accessibilityElement. Add the missing legacy textbox mapping and an RNTester example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../react-native/React/Base/RCTConvert.mm | 1 + .../View/RCTViewComponentView.mm | 53 ++++++++++++++++-- .../Accessibility/AccessibilityExample.js | 55 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/react-native/React/Base/RCTConvert.mm b/packages/react-native/React/Base/RCTConvert.mm index 65e469c99106..7e3a160d7ccd 100644 --- a/packages/react-native/React/Base/RCTConvert.mm +++ b/packages/react-native/React/Base/RCTConvert.mm @@ -1664,6 +1664,7 @@ + (NSString*)accessibilityRoleFromTrait:(NSString*)trait @"tab": NSAccessibilityButtonRole, // no exact match on macOS @"tablist": NSAccessibilityTabGroupRole, @"text": NSAccessibilityStaticTextRole, // also a legacy iOS accessibilityTraits + @"textbox": NSAccessibilityTextFieldRole, @"timer": NSAccessibilityStaticTextRole, // no exact match on macOS @"toolbar": NSAccessibilityToolbarRole, // Roles/traits that are macOS specific and are used by some of the core components (Lists): 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 473eab307dca..4f3e86071093 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -36,6 +36,7 @@ #endif #if TARGET_OS_OSX // [macOS +#import #import #import #import @@ -45,6 +46,40 @@ const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f; +#if TARGET_OS_OSX // [macOS +// The role an accessibility element reports when React Native does not override it. NSControl subclasses, such as the +// NSTextField behind a single-line TextInput, report their role through their cell; the control's own +// `accessibilityRole` getter returns NSAccessibilityUnknownRole until a role is set explicitly. +static NSAccessibilityRole RCTDefaultAccessibilityRoleForElement(NSView *element) +{ + if ([element isKindOfClass:[NSControl class]]) { + NSAccessibilityRole cellRole = ((NSControl *)element).cell.accessibilityRole; + if (cellRole != nil) { + return cellRole; + } + } + return NSAccessibilityUnknownRole; +} + +// Matches Paper (`-[RCTViewManager updateAccessibilityRole:withDefaultView:]`): `role` takes precedence over +// `accessibilityRole`, and the element's default role is used when neither is set. +static NSAccessibilityRole RCTAccessibilityRoleFromViewProps(const ViewProps &props, NSView *element) +{ + if (props.role != Role::None) { + return [RCTConvert accessibilityRoleFromTraits:RCTNSStringFromString(toString(props.role)) useAriaMappings:YES]; + } + if (!props.accessibilityRole.empty()) { + return [RCTConvert accessibilityRoleFromTraits:RCTNSStringFromString(props.accessibilityRole) useAriaMappings:NO]; + } + NSAccessibilityRole traitsRole = + RCTAccessibilityRoleFromTraits(RCTUIAccessibilityTraitsFromAccessibilityTraits(props.accessibilityTraits)); + if (![traitsRole isEqualToString:NSAccessibilityUnknownRole]) { + return traitsRole; + } + return RCTDefaultAccessibilityRoleForElement(element); +} +#endif // macOS] + @implementation RCTViewComponentView { RCTPlatformColor *_backgroundColor; // [macOS] CALayer *_backgroundColorLayer; @@ -487,19 +522,27 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } } +#if !TARGET_OS_OSX // [macOS] // `accessibilityTraits` if (oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) { -#if !TARGET_OS_OSX // [macOS] self.accessibilityElement.accessibilityTraits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); + } #else // [macOS - // On macOS, accessibilityElement returns self (NSView*) which doesn't have accessibilityTraits. - // Set role directly on self based on traits. + // `role`, `accessibilityRole` and `accessibilityTraits` + // NSAccessibility has no traits, so all three resolve to a single role on the accessibility element. + if (oldViewProps.role != newViewProps.role || oldViewProps.accessibilityRole != newViewProps.accessibilityRole || + oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) { + self.accessibilityElement.accessibilityRole = + RCTAccessibilityRoleFromViewProps(newViewProps, self.accessibilityElement); + } + + // `accessibilityTraits` + if (oldViewProps.accessibilityTraits != newViewProps.accessibilityTraits) { RCTUIAccessibilityTraits traits = RCTUIAccessibilityTraitsFromAccessibilityTraits(newViewProps.accessibilityTraits); - self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits); self.accessibilityEnabled = (traits & RCTUIAccessibilityTraitNotEnabled) == 0; -#endif // macOS] } +#endif // macOS] #if !TARGET_OS_OSX // [macOS] // `accessibilityState` diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 7064fd2395e3..dbce3a687434 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -2229,6 +2229,51 @@ function AriaHiddenExample(): React.Node { ); } +// [macOS +// 'none' clears the role, so the element falls back to its default role. +const macOSDynamicRoles: $ReadOnlyArray< + 'menuitem' | 'option' | 'tab' | 'none', +> = ['menuitem', 'option', 'tab', 'none']; + +function MacOSAccessibilityRoleExample(): React.Node { + const [roleIndex, setRoleIndex] = useState(0); + const dynamicRole = macOSDynamicRoles[roleIndex]; + return ( + + + role="menuitem" (AXMenuItem) + + + role="option" (AXStaticText) + + + role="tab" (AXRadioButton) + + + accessibilityRole="textbox" (AXTextField) + + + {`role="${dynamicRole}"`} + + +