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 d13cd8a600af..3832a7958803 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -12,6 +12,8 @@ #import #import #import +#import +#import #import #import @@ -44,14 +46,61 @@ // interactivity through a grouping accessibility element (rather than the // underlying control) are otherwise skipped by the focus engine, leaving // keyboard-only users unable to reach them. -static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view) +// +// The trait mask alone is not sufficient, because it is a lossy projection of +// the role: `checkbox`, `radio`, `combobox`, `menuitem`, `spinbutton`, `tab` +// and friends deliberately carry no interactive trait, since VoiceOver conveys +// them through `accessibilityValue` instead. The role is therefore consulted +// as well, otherwise those controls stay unreachable by keyboard. +static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewProps &props) { if (!view.isAccessibilityElement) { return NO; } + UIAccessibilityTraits interactiveTraits = UIAccessibilityTraitButton | UIAccessibilityTraitLink | UIAccessibilityTraitSearchField | UIAccessibilityTraitKeyboardKey | UIAccessibilityTraitAdjustable; - return (view.accessibilityTraits & interactiveTraits) != 0; + if ((view.accessibilityTraits & interactiveTraits) != 0) { + return YES; + } + + // `role` wins over the legacy `accessibilityRole` when both are set, matching + // how the traits themselves are resolved. + if (props.role != Role::None) { + static const std::unordered_set interactiveRoles{ + Role::Button, + Role::Checkbox, + Role::Combobox, + Role::Link, + Role::Menuitem, + Role::Option, + Role::Radio, + Role::Searchbox, + Role::Slider, + Role::Spinbutton, + Role::Switch, + Role::Tab, + Role::Treeitem}; + return interactiveRoles.contains(props.role); + } + + static const std::unordered_set interactiveAccessibilityRoles{ + "adjustable", + "button", + "checkbox", + "combobox", + "dropdownlist", + "imagebutton", + "keyboardkey", + "link", + "menuitem", + "radio", + "search", + "spinbutton", + "switch", + "tab", + "togglebutton"}; + return interactiveAccessibilityRoles.contains(props.accessibilityRole); } #endif @@ -1508,7 +1557,7 @@ - (BOOL)wantsToCooptLabel - (BOOL)canBecomeFocused { #if !TARGET_OS_TV - return RCTViewIsInteractiveAccessibilityElement(self) || _focusable; + return _focusable || RCTViewIsInteractiveAccessibilityElement(self, static_cast(*_props)); #else return _focusable; #endif diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index 6d5484fe2567..db374b35f10a 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -183,4 +183,58 @@ - (void)testHitTestAfterScaleTransitionedToZeroReturnsNil XCTAssertNil([view hitTest:CGPointMake(50, 50) withEvent:nil]); } +#pragma mark - Full Keyboard Access focusability + +static RCTViewComponentView *makeViewWithRole(bool accessible, const std::string &accessibilityRole) +{ + RCTViewComponentView *view = [RCTViewComponentView new]; + auto props = std::make_shared(); + props->accessible = accessible; + props->accessibilityRole = accessibilityRole; + [view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()]; + return view; +} + +- (void)testInteractiveRolesWithoutUIKitTraitsAreKeyboardFocusable +{ + // These roles intentionally map to no interactive UIKit trait, because + // VoiceOver conveys them through accessibilityValue. They must still be + // reachable under Full Keyboard Access. + for (const std::string &role : {"checkbox", "radio", "combobox", "dropdownlist", "menuitem", "spinbutton", "tab"}) { + RCTViewComponentView *view = makeViewWithRole(true, role); + XCTAssertTrue(view.canBecomeFocused, @"role '%s' should be keyboard focusable", role.c_str()); + } +} + +- (void)testTraitBackedInteractiveRolesRemainKeyboardFocusable +{ + for (const std::string &role : + {"button", "togglebutton", "link", "search", "keyboardkey", "adjustable", "imagebutton", "switch"}) { + RCTViewComponentView *view = makeViewWithRole(true, role); + XCTAssertTrue(view.canBecomeFocused, @"role '%s' should be keyboard focusable", role.c_str()); + } +} + +- (void)testNonInteractiveRolesAreNotKeyboardFocusable +{ + for (const std::string &role : {"none", "text", "header", "image", "progressbar", "timer"}) { + RCTViewComponentView *view = makeViewWithRole(true, role); + XCTAssertFalse(view.canBecomeFocused, @"role '%s' should not be keyboard focusable", role.c_str()); + } +} + +- (void)testNonAccessibleViewIsNotKeyboardFocusable +{ + // An interactive role on a view opted out of accessibility must stay + // unreachable, otherwise the focus ring lands on an invisible element. + RCTViewComponentView *view = makeViewWithRole(false, "button"); + XCTAssertFalse(view.canBecomeFocused); +} + +- (void)testViewWithoutRoleIsNotKeyboardFocusable +{ + RCTViewComponentView *view = makeViewWithRole(true, ""); + XCTAssertFalse(view.canBecomeFocused); +} + @end