Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
#import <QuartzCore/QuartzCore.h>
#import <objc/runtime.h>
#import <ranges>
#import <string_view>
#import <unordered_set>

#import <RCTSwiftUIWrapper/RCTSwiftUIContainerViewWrapper.h>
#import <React/RCTAssert.h>
Expand Down Expand Up @@ -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<Role> 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<std::string_view> interactiveAccessibilityRoles{
"adjustable",
"button",
"checkbox",
"combobox",
"dropdownlist",
"imagebutton",
"keyboardkey",
"link",
"menuitem",
"radio",
"search",
"spinbutton",
"switch",
"tab",
"togglebutton"};
return interactiveAccessibilityRoles.contains(props.accessibilityRole);
}
#endif

Expand Down Expand Up @@ -1508,7 +1557,7 @@ - (BOOL)wantsToCooptLabel
- (BOOL)canBecomeFocused
{
#if !TARGET_OS_TV
return RCTViewIsInteractiveAccessibilityElement(self) || _focusable;
return _focusable || RCTViewIsInteractiveAccessibilityElement(self, static_cast<const ViewProps &>(*_props));
#else
return _focusable;
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewProps>();
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
Loading