Skip to content

Commit

Permalink
Native ARIA Roles: Android Paper + Fabric (#37306)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #37306

### Stack

ARIA roles in React Native are implemented on top of accessibilityRole. This is lossy because there are many more ARIA roles than accessibilityRole. This is especially true for RN on desktop where accessibilityRole was designed around accessibility APIs only available on mobile.

This series of changes aims to change this implementation to instead pass the ARIA role to native, alongside any existing accessibilityRole. This gives the platform more control in exactly how to map an ARIA role to native behavior.

As an example, this would allow mapping any ARIA role to AutomationControlType on Windows without needing to fork to add new options to accessibilityRole.

It also allows greater implementation flexibility for other platforms down the line, but for now, iOS and Android behave the same as before (though with their implementation living in native).

### Diff

This replicates the roles to Java. When using MapBuffer we pass the role by ordinal, assuming they keep in sync. We otherwise still pass by string matching the JS side.

Previous implementation kept `accessibilityRole` on a view tag of the native view. This adds `role` as a view tag as well.

For now, to reuse the existing code, we then expose a single function to query `AccessibilityRole` from the combined view tags. This will do the same mapping previously done in JS, so that any code previously reading for an `AccessibilityRole` will now get one derived from the role view tag if present.

Changelog: [Internal]

Reviewed By: sammy-SC

Differential Revision: D45431381

fbshipit-source-id: a72c7880d41b5cf2c4e1c1f3ebfa6832ce8b4250
  • Loading branch information
NickGerleman authored and facebook-github-bot committed May 13, 2023
1 parent 9db5fa2 commit 02ec60b
Show file tree
Hide file tree
Showing 18 changed files with 274 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.facebook.react.common.MapBuilder;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role;
import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.events.PointerEventHelper;
import com.facebook.react.uimanager.util.ReactFindViewUtil;
Expand Down Expand Up @@ -234,9 +235,10 @@ public void setAccessibilityHint(@NonNull T view, @Nullable String accessibility
@ReactProp(name = ViewProps.ACCESSIBILITY_ROLE)
public void setAccessibilityRole(@NonNull T view, @Nullable String accessibilityRole) {
if (accessibilityRole == null) {
return;
view.setTag(R.id.accessibility_role, null);
} else {
view.setTag(R.id.accessibility_role, AccessibilityRole.fromValue(accessibilityRole));
}
view.setTag(R.id.accessibility_role, AccessibilityRole.fromValue(accessibilityRole));
}

@Override
Expand Down Expand Up @@ -380,6 +382,16 @@ public void setImportantForAccessibility(
}
}

@Override
@ReactProp(name = ViewProps.ROLE)
public void setRole(@NonNull T view, @Nullable String role) {
if (role == null) {
view.setTag(R.id.role, null);
} else {
view.setTag(R.id.role, Role.fromValue(role));
}
}

@Override
@Deprecated
@ReactProp(name = ViewProps.ROTATION)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public void setShadowColor(@NonNull T view, int shadowColor) {}
public void setImportantForAccessibility(
@NonNull T view, @Nullable String importantForAccessibility) {}

@Override
public void setRole(@NonNull T view, @Nullable String role) {}

@Override
public void setNativeId(@NonNull T view, String nativeId) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ public void setProperty(T view, String propName, @Nullable Object value) {
case ViewProps.IMPORTANT_FOR_ACCESSIBILITY:
mViewManager.setImportantForAccessibility(view, (String) value);
break;
case ViewProps.ROLE:
mViewManager.setRole(view, (String) value);
break;
case ViewProps.NATIVE_ID:
mViewManager.setNativeId(view, (String) value);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public interface BaseViewManagerInterface<T extends View> {

void setImportantForAccessibility(T view, @Nullable String importantForAccessibility);

void setRole(T view, @Nullable String role);

void setNativeId(T view, @Nullable String nativeId);

void setAccessibilityLabelledBy(T view, @Nullable Dynamic nativeId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,87 @@ private void scheduleAccessibilityEventSender(View host) {
mHandler.sendMessageDelayed(msg, TIMEOUT_SEND_ACCESSIBILITY_EVENT);
}

/**
* An ARIA Role representable by View's `role` prop. Ordinals should be kept in sync with
* `facebook::react::Role`.
*/
public enum Role {
ALERT,
ALERTDIALOG,
APPLICATION,
ARTICLE,
BANNER,
BUTTON,
CELL,
CHECKBOX,
COLUMNHEADER,
COMBOBOX,
COMPLEMENTARY,
CONTENTINFO,
DEFINITION,
DIALOG,
DIRECTORY,
DOCUMENT,
FEED,
FIGURE,
FORM,
GRID,
GROUP,
HEADING,
IMG,
LINK,
LIST,
LISTITEM,
LOG,
MAIN,
MARQUEE,
MATH,
MENU,
MENUBAR,
MENUITEM,
METER,
NAVIGATION,
NONE,
NOTE,
OPTION,
PRESENTATION,
PROGRESSBAR,
RADIO,
RADIOGROUP,
REGION,
ROW,
ROWGROUP,
ROWHEADER,
SCROLLBAR,
SEARCHBOX,
SEPARATOR,
SLIDER,
SPINBUTTON,
STATUS,
SUMMARY,
SWITCH,
TAB,
TABLE,
TABLIST,
TABPANEL,
TERM,
TIMER,
TOOLBAR,
TOOLTIP,
TREE,
TREEGRID,
TREEITEM;

public static @Nullable Role fromValue(@Nullable String value) {
for (Role role : Role.values()) {
if (role.name().equalsIgnoreCase(value)) {
return role;
}
}
return null;
}
}

/**
* These roles are defined by Google's TalkBack screen reader, and this list should be kept up to
* date with their implementation. Details can be seen in their source code here:
Expand Down Expand Up @@ -221,6 +302,75 @@ public static AccessibilityRole fromValue(@Nullable String value) {
}
throw new IllegalArgumentException("Invalid accessibility role value: " + value);
}

public static @Nullable AccessibilityRole fromRole(Role role) {
switch (role) {
case ALERT:
return AccessibilityRole.ALERT;
case BUTTON:
return AccessibilityRole.BUTTON;
case CHECKBOX:
return AccessibilityRole.CHECKBOX;
case COMBOBOX:
return AccessibilityRole.COMBOBOX;
case GRID:
return AccessibilityRole.GRID;
case HEADING:
return AccessibilityRole.HEADER;
case IMG:
return AccessibilityRole.IMAGE;
case LINK:
return AccessibilityRole.LINK;
case LIST:
return AccessibilityRole.LIST;
case MENU:
return AccessibilityRole.MENU;
case MENUBAR:
return AccessibilityRole.MENUBAR;
case MENUITEM:
return AccessibilityRole.MENUITEM;
case NONE:
return AccessibilityRole.NONE;
case PROGRESSBAR:
return AccessibilityRole.PROGRESSBAR;
case RADIO:
return AccessibilityRole.RADIO;
case RADIOGROUP:
return AccessibilityRole.RADIOGROUP;
case SCROLLBAR:
return AccessibilityRole.SCROLLBAR;
case SEARCHBOX:
return AccessibilityRole.SEARCH;
case SLIDER:
return AccessibilityRole.ADJUSTABLE;
case SPINBUTTON:
return AccessibilityRole.SPINBUTTON;
case SUMMARY:
return AccessibilityRole.SUMMARY;
case SWITCH:
return AccessibilityRole.SWITCH;
case TAB:
return AccessibilityRole.TAB;
case TABLIST:
return AccessibilityRole.TABLIST;
case TIMER:
return AccessibilityRole.TIMER;
case TOOLBAR:
return AccessibilityRole.TOOLBAR;
default:
// No mapping from ARIA role to AccessibilityRole
return null;
}
}

public static @Nullable AccessibilityRole fromViewTag(View view) {
Role role = (Role) view.getTag(R.id.role);
if (role != null) {
return AccessibilityRole.fromRole(role);
} else {
return (AccessibilityRole) view.getTag(R.id.accessibility_role);
}
}
}

private final HashMap<Integer, String> mAccessibilityActionsMap;
Expand Down Expand Up @@ -267,8 +417,7 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo
? AccessibilityNodeInfoCompat.ACTION_COLLAPSE
: AccessibilityNodeInfoCompat.ACTION_EXPAND);
}
final AccessibilityRole accessibilityRole =
(AccessibilityRole) host.getTag(R.id.accessibility_role);
final AccessibilityRole accessibilityRole = AccessibilityRole.fromViewTag(host);
final String accessibilityHint = (String) host.getTag(R.id.accessibility_hint);
if (accessibilityRole != null) {
setRole(info, accessibilityRole, host.getContext());
Expand Down Expand Up @@ -551,7 +700,8 @@ public static void setDelegate(
|| view.getTag(R.id.accessibility_actions) != null
|| view.getTag(R.id.react_test_id) != null
|| view.getTag(R.id.accessibility_collection_item) != null
|| view.getTag(R.id.accessibility_links) != null)) {
|| view.getTag(R.id.accessibility_links) != null
|| view.getTag(R.id.role) != null)) {
ViewCompat.setAccessibilityDelegate(
view,
new ReactAccessibilityDelegate(view, originalFocus, originalImportantForAccessibility));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ public class ViewProps {
public static final String ACCESSIBILITY_VALUE = "accessibilityValue";
public static final String ACCESSIBILITY_LABELLED_BY = "accessibilityLabelledBy";
public static final String IMPORTANT_FOR_ACCESSIBILITY = "importantForAccessibility";
public static final String ROLE = "role";

// DEPRECATED
public static final String ROTATION = "rotation";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public ReactDrawerLayout(ReactContext reactContext) {
public void onInitializeAccessibilityNodeInfo(
View host, AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
final AccessibilityRole accessibilityRole =
(AccessibilityRole) host.getTag(R.id.accessibility_role);

final AccessibilityRole accessibilityRole = AccessibilityRole.fromViewTag(host);
if (accessibilityRole != null) {
info.setClassName(AccessibilityRole.getValue(accessibilityRole));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.facebook.react.bridge.ReactSoftExceptionLogger;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.uimanager.ReactAccessibilityDelegate;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;

public class ReactScrollViewAccessibilityDelegate extends AccessibilityDelegateCompat {
private final String TAG = ReactScrollViewAccessibilityDelegate.class.getSimpleName();
Expand Down Expand Up @@ -122,8 +123,8 @@ private void onInitializeAccessibilityEventInternal(View view, AccessibilityEven

private void onInitializeAccessibilityNodeInfoInternal(
View view, AccessibilityNodeInfoCompat info) {
final ReactAccessibilityDelegate.AccessibilityRole accessibilityRole =
(ReactAccessibilityDelegate.AccessibilityRole) view.getTag(R.id.accessibility_role);

final AccessibilityRole accessibilityRole = AccessibilityRole.fromViewTag(view);

if (accessibilityRole != null) {
ReactAccessibilityDelegate.setRole(info, accessibilityRole, view.getContext());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import com.facebook.react.uimanager.LayoutShadowNode;
import com.facebook.react.uimanager.NativeViewHierarchyOptimizer;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role;
import com.facebook.react.uimanager.ReactShadowNode;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.annotations.ReactProp;
Expand All @@ -36,7 +38,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* {@link ReactShadowNode} abstract class for spannable text nodes.
Expand Down Expand Up @@ -180,7 +181,11 @@ private static void buildSpannedFromShadowNode(
new SetSpanOperation(
start, end, new ReactBackgroundColorSpan(textShadowNode.mBackgroundColor)));
}
if (textShadowNode.mIsAccessibilityLink) {
boolean roleIsLink =
textShadowNode.mRole != null
? textShadowNode.mRole == Role.LINK
: textShadowNode.mAccessibilityRole == AccessibilityRole.LINK;
if (roleIsLink) {
ops.add(
new SetSpanOperation(start, end, new ReactClickableSpan(textShadowNode.getReactTag())));
}
Expand Down Expand Up @@ -325,7 +330,9 @@ protected Spannable spannedFromShadowNode(
protected int mColor;
protected boolean mIsBackgroundColorSet = false;
protected int mBackgroundColor;
protected boolean mIsAccessibilityLink = false;

protected @Nullable AccessibilityRole mAccessibilityRole = null;
protected @Nullable Role mRole = null;

protected int mNumberOfLines = UNSET;
protected int mTextAlign = Gravity.NO_GRAVITY;
Expand Down Expand Up @@ -499,9 +506,17 @@ public void setBackgroundColor(@Nullable Integer color) {
}

@ReactProp(name = ViewProps.ACCESSIBILITY_ROLE)
public void setIsAccessibilityLink(@Nullable String accessibilityRole) {
public void setAccessibilityRole(@Nullable String accessibilityRole) {
if (isVirtual()) {
mAccessibilityRole = AccessibilityRole.fromValue(accessibilityRole);
markUpdated();
}
}

@ReactProp(name = ViewProps.ROLE)
public void setRole(@Nullable String role) {
if (isVirtual()) {
mIsAccessibilityLink = Objects.equals(accessibilityRole, "link");
mRole = Role.fromValue(role);
markUpdated();
}
}
Expand Down
Loading

0 comments on commit 02ec60b

Please sign in to comment.