Skip to content

Commit 87d3504

Browse files
fkgozalifacebook-github-bot
authored andcommitted
Make role-based interactive views keyboard-focusable on iOS
Summary: On iOS, Full Keyboard Access decides whether a Fabric view can take keyboard focus from its UIKit accessibility trait mask. That mask is a lossy projection of the `role` / `accessibilityRole` prop: `checkbox`, `radio`, `combobox`, `dropdownlist`, `menuitem`, `spinbutton`, `tab`, and the ARIA `option`, `searchbox`, `slider` and `treeitem` all resolve to no interactive trait, because VoiceOver conveys those roles through `accessibilityValue` instead. Controls authored with any of them were unreachable with a keyboard - a WCAG 2.1.1 (Keyboard) failure. Derive focusability from the role as well as the trait mask. Behaviour is otherwise unchanged: views that opt out of accessibility (`accessible={false}`) and views with no interactive role stay unfocusable, VoiceOver is untouched (it keys off `isAccessibilityElement`), and tvOS keeps its existing `focusable` path. Changelog: [iOS][Fixed] - Views with checkbox, radio, combobox, dropdownlist, menuitem, spinbutton, tab, option, searchbox, slider and treeitem roles are now reachable via Full Keyboard Access Differential Revision: D114784056
1 parent df5e6f6 commit 87d3504

2 files changed

Lines changed: 106 additions & 3 deletions

File tree

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
#import <QuartzCore/QuartzCore.h>
1313
#import <objc/runtime.h>
1414
#import <ranges>
15+
#import <string_view>
16+
#import <unordered_set>
1517

1618
#import <RCTSwiftUIWrapper/RCTSwiftUIContainerViewWrapper.h>
1719
#import <React/RCTAssert.h>
@@ -44,14 +46,61 @@
4446
// interactivity through a grouping accessibility element (rather than the
4547
// underlying control) are otherwise skipped by the focus engine, leaving
4648
// keyboard-only users unable to reach them.
47-
static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view)
49+
//
50+
// The trait mask alone is not sufficient, because it is a lossy projection of
51+
// the role: `checkbox`, `radio`, `combobox`, `menuitem`, `spinbutton`, `tab`
52+
// and friends deliberately carry no interactive trait, since VoiceOver conveys
53+
// them through `accessibilityValue` instead. The role is therefore consulted
54+
// as well, otherwise those controls stay unreachable by keyboard.
55+
static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewProps &props)
4856
{
4957
if (!view.isAccessibilityElement) {
5058
return NO;
5159
}
60+
5261
UIAccessibilityTraits interactiveTraits = UIAccessibilityTraitButton | UIAccessibilityTraitLink |
5362
UIAccessibilityTraitSearchField | UIAccessibilityTraitKeyboardKey | UIAccessibilityTraitAdjustable;
54-
return (view.accessibilityTraits & interactiveTraits) != 0;
63+
if ((view.accessibilityTraits & interactiveTraits) != 0) {
64+
return YES;
65+
}
66+
67+
// `role` wins over the legacy `accessibilityRole` when both are set, matching
68+
// how the traits themselves are resolved.
69+
if (props.role != Role::None) {
70+
static const std::unordered_set<Role> interactiveRoles{
71+
Role::Button,
72+
Role::Checkbox,
73+
Role::Combobox,
74+
Role::Link,
75+
Role::Menuitem,
76+
Role::Option,
77+
Role::Radio,
78+
Role::Searchbox,
79+
Role::Slider,
80+
Role::Spinbutton,
81+
Role::Switch,
82+
Role::Tab,
83+
Role::Treeitem};
84+
return interactiveRoles.contains(props.role);
85+
}
86+
87+
static const std::unordered_set<std::string_view> interactiveAccessibilityRoles{
88+
"adjustable",
89+
"button",
90+
"checkbox",
91+
"combobox",
92+
"dropdownlist",
93+
"imagebutton",
94+
"keyboardkey",
95+
"link",
96+
"menuitem",
97+
"radio",
98+
"search",
99+
"spinbutton",
100+
"switch",
101+
"tab",
102+
"togglebutton"};
103+
return interactiveAccessibilityRoles.contains(props.accessibilityRole);
55104
}
56105
#endif
57106

@@ -1508,7 +1557,7 @@ - (BOOL)wantsToCooptLabel
15081557
- (BOOL)canBecomeFocused
15091558
{
15101559
#if !TARGET_OS_TV
1511-
return RCTViewIsInteractiveAccessibilityElement(self) || _focusable;
1560+
return _focusable || RCTViewIsInteractiveAccessibilityElement(self, static_cast<const ViewProps &>(*_props));
15121561
#else
15131562
return _focusable;
15141563
#endif

packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,58 @@ - (void)testHitTestAfterScaleTransitionedToZeroReturnsNil
183183
XCTAssertNil([view hitTest:CGPointMake(50, 50) withEvent:nil]);
184184
}
185185

186+
#pragma mark - Full Keyboard Access focusability
187+
188+
static RCTViewComponentView *makeViewWithRole(bool accessible, const std::string &accessibilityRole)
189+
{
190+
RCTViewComponentView *view = [RCTViewComponentView new];
191+
auto props = std::make_shared<ViewProps>();
192+
props->accessible = accessible;
193+
props->accessibilityRole = accessibilityRole;
194+
[view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()];
195+
return view;
196+
}
197+
198+
- (void)testInteractiveRolesWithoutUIKitTraitsAreKeyboardFocusable
199+
{
200+
// These roles intentionally map to no interactive UIKit trait, because
201+
// VoiceOver conveys them through accessibilityValue. They must still be
202+
// reachable under Full Keyboard Access.
203+
for (const std::string &role : {"checkbox", "radio", "combobox", "dropdownlist", "menuitem", "spinbutton", "tab"}) {
204+
RCTViewComponentView *view = makeViewWithRole(true, role);
205+
XCTAssertTrue(view.canBecomeFocused, @"role '%s' should be keyboard focusable", role.c_str());
206+
}
207+
}
208+
209+
- (void)testTraitBackedInteractiveRolesRemainKeyboardFocusable
210+
{
211+
for (const std::string &role :
212+
{"button", "togglebutton", "link", "search", "keyboardkey", "adjustable", "imagebutton", "switch"}) {
213+
RCTViewComponentView *view = makeViewWithRole(true, role);
214+
XCTAssertTrue(view.canBecomeFocused, @"role '%s' should be keyboard focusable", role.c_str());
215+
}
216+
}
217+
218+
- (void)testNonInteractiveRolesAreNotKeyboardFocusable
219+
{
220+
for (const std::string &role : {"none", "text", "header", "image", "progressbar", "timer"}) {
221+
RCTViewComponentView *view = makeViewWithRole(true, role);
222+
XCTAssertFalse(view.canBecomeFocused, @"role '%s' should not be keyboard focusable", role.c_str());
223+
}
224+
}
225+
226+
- (void)testNonAccessibleViewIsNotKeyboardFocusable
227+
{
228+
// An interactive role on a view opted out of accessibility must stay
229+
// unreachable, otherwise the focus ring lands on an invisible element.
230+
RCTViewComponentView *view = makeViewWithRole(false, "button");
231+
XCTAssertFalse(view.canBecomeFocused);
232+
}
233+
234+
- (void)testViewWithoutRoleIsNotKeyboardFocusable
235+
{
236+
RCTViewComponentView *view = makeViewWithRole(true, "");
237+
XCTAssertFalse(view.canBecomeFocused);
238+
}
239+
186240
@end

0 commit comments

Comments
 (0)