[add] support for accessibilityRole and accessibilityStates

React Native 0.57 introduced 'accessibilityRole' and
'accessibilityStates' as cross-platform accessibility APIs to replace
'accessibilityComponentType' and 'accessibilityTraits' for Android and
iOS.

React Native for Web has supported the 'accessibilityRole' for a while.
This patch maps some of the values defined in React Native to web
equivalents, and continues to allow a larger selection of roles for web
apps. It also adds support for 'accessibilityStates', mapping values to
ARIA states with boolean values and expanding support beyond 'disabled'
and 'selected'.

Fix #1112
Close #1113
This commit is contained in:
Nicolas Gallagher
2018-09-18 18:51:04 -07:00
parent 000b92e707
commit 4040151ee6
9 changed files with 104 additions and 52 deletions
+13 -1
View File
@@ -11,7 +11,7 @@
import EdgeInsetsPropType, { type EdgeInsetsProp } from '../EdgeInsetsPropType';
import StyleSheetPropType from '../../modules/StyleSheetPropType';
import ViewStylePropTypes from './ViewStylePropTypes';
import { any, array, bool, func, object, oneOf, oneOfType, string } from 'prop-types';
import { any, array, arrayOf, bool, func, object, oneOf, oneOfType, string } from 'prop-types';
const stylePropType = StyleSheetPropType(ViewStylePropTypes);
@@ -33,6 +33,7 @@ export type ViewProps = {
accessibilityLabel?: string,
accessibilityLiveRegion?: 'none' | 'polite' | 'assertive',
accessibilityRole?: string,
accessibilityStates?: Array<string>,
accessibilityTraits?: string | Array<string>,
accessible?: boolean,
children?: any,
@@ -83,6 +84,17 @@ const ViewPropTypes = {
accessibilityLabel: string,
accessibilityLiveRegion: oneOf(['assertive', 'none', 'polite']),
accessibilityRole: string,
accessibilityStates: arrayOf(oneOf([
'disabled',
'selected',
/* web-only */
'busy',
'checked',
'expanded',
'grabbed',
'invalid',
'pressed'
])),
accessibilityTraits: oneOfType([array, string]),
accessible: bool,
children: any,
@@ -1,8 +1,9 @@
const whitelist = {
const supportedProps = {
accessibilityComponentType: true,
accessibilityLabel: true,
accessibilityLiveRegion: true,
accessibilityRole: true,
accessibilityStates: true,
accessibilityTraits: true,
accessible: true,
children: true,
@@ -67,7 +68,7 @@ const filterSupportedProps = props => {
const safeProps = {};
for (const prop in props) {
if (props.hasOwnProperty(prop)) {
if (whitelist[prop] || prop.indexOf('aria-') === 0 || prop.indexOf('data-') === 0) {
if (supportedProps[prop] || prop.indexOf('aria-') === 0 || prop.indexOf('data-') === 0) {
safeProps[prop] = props[prop];
}
}
@@ -25,4 +25,11 @@ describe('modules/AccessibilityUtil/propsToAriaRole', () => {
})
).toEqual('link');
});
test('when "accessibilityRole" is a native-only value', () => {
expect(propsToAriaRole({ accessibilityRole: 'none' })).toEqual('presentation');
expect(propsToAriaRole({ accessibilityRole: 'imagebutton' })).toEqual(undefined);
// not really native-only, but used to allow Web to render <label> around TextInput
expect(propsToAriaRole({ accessibilityRole: 'label' })).toEqual(undefined);
});
});
@@ -7,6 +7,8 @@
* @flow
*/
const isDisabled = (props: Object) => props.disabled || props['aria-disabled'];
const isDisabled = (props: Object) =>
props.disabled ||
(Array.isArray(props.accessibilityStates) && props.accessibilityStates.indexOf('disabled') > -1);
export default isDisabled;
@@ -15,7 +15,6 @@ const roleComponents = {
complementary: 'aside',
contentinfo: 'footer',
form: 'form',
label: 'label',
link: 'a',
list: 'ul',
listitem: 'li',
@@ -27,6 +26,11 @@ const roleComponents = {
const emptyObject = {};
const propsToAccessibilityComponent = (props: Object = emptyObject) => {
// special-case for "label" role which doesn't map to an ARIA role
if (props.accessibilityRole === 'label') {
return 'label';
}
const role = propsToAriaRole(props);
if (role) {
if (role === 'heading') {
@@ -23,6 +23,21 @@ const accessibilityTraitsToRole = {
summary: 'region'
};
const accessibilityRoleToWebRole = {
adjustable: 'slider',
button: 'button',
header: 'heading',
image: 'img',
imagebutton: null,
keyboardkey: null,
label: null,
link: 'link',
none: 'presentation',
search: 'search',
summary: 'region',
text: null
};
/**
* Provides compatibility with React Native's "accessibilityTraits" (iOS) and
* "accessibilityComponentType" (Android), converting them to equivalent ARIA
@@ -34,7 +49,11 @@ const propsToAriaRole = ({
accessibilityTraits
}: Object) => {
if (accessibilityRole) {
return accessibilityRole;
const inferredRole = accessibilityRoleToWebRole[accessibilityRole];
if (inferredRole !== null) {
// ignore roles that don't map to web
return inferredRole || accessibilityRole;
}
}
if (accessibilityTraits) {
const trait = Array.isArray(accessibilityTraits) ? accessibilityTraits[0] : accessibilityTraits;
@@ -35,18 +35,12 @@ describe('modules/createDOMProps', () => {
expect(createProps({ accessibilityRole, disabled: true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true, tabIndex: '-1' })
);
expect(createProps({ accessibilityRole, 'aria-disabled': true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true, tabIndex: '-1' })
);
});
test('when "disabled" is false', () => {
expect(createProps({ accessibilityRole, disabled: false })).toEqual(
expect.objectContaining({ 'data-focusable': true })
);
expect(createProps({ accessibilityRole, 'aria-disabled': false })).toEqual(
expect.objectContaining({ 'data-focusable': true })
);
});
test('when "importantForAccessibility" is "no"', () => {
@@ -88,18 +82,12 @@ describe('modules/createDOMProps', () => {
expect(createProps({ accessibilityRole, disabled: true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true })
);
expect(createProps({ accessibilityRole, 'aria-disabled': true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true })
);
});
test('when "disabled" is false', () => {
expect(createProps({ accessibilityRole, disabled: false })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
expect(createProps({ accessibilityRole, 'aria-disabled': false })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "importantForAccessibility" is "no"', () => {
@@ -164,12 +152,17 @@ describe('modules/createDOMProps', () => {
expect(props['aria-live']).toEqual('off');
});
describe('prop "accessibilityRole"', () => {
test('does not become "role" when value is "label"', () => {
const accessibilityRole = 'label';
const props = createProps({ accessibilityRole });
expect(props.role).toBeUndefined();
});
test('prop "accessibilityRole" becomes "role"', () => {
const accessibilityRole = 'button';
const props = createProps({ accessibilityRole });
expect(props.role).toEqual('button');
});
test('prop "accessibilityStates" becomes ARIA states', () => {
const accessibilityStates = ['disabled', 'selected'];
const props = createProps({ accessibilityStates });
expect(props['aria-disabled']).toEqual(true);
expect(props['aria-selected']).toEqual(true);
});
test('prop "className" is preserved', () => {
@@ -76,6 +76,7 @@ const createDOMProps = (component, props, styleResolver) => {
const {
accessibilityLabel,
accessibilityLiveRegion,
accessibilityStates,
importantForAccessibility,
nativeID,
placeholderTextColor,
@@ -104,7 +105,12 @@ const createDOMProps = (component, props, styleResolver) => {
if (accessibilityLiveRegion && accessibilityLiveRegion.constructor === String) {
domProps['aria-live'] = accessibilityLiveRegion === 'none' ? 'off' : accessibilityLiveRegion;
}
if (role && role.constructor === String && role !== 'label') {
if (Array.isArray(accessibilityStates)) {
for (let i = 0; i < accessibilityStates.length; i += 1) {
domProps[`aria-${accessibilityStates[i]}`] = true;
}
}
if (role && role.constructor === String) {
domProps.role = role;
}