Skip to content

Commit

Permalink
[add] support for accessibilityRole and accessibilityStates
Browse files Browse the repository at this point in the history
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
  • Loading branch information
necolas committed Jan 23, 2019
1 parent 000b92e commit 4040151
Show file tree
Hide file tree
Showing 9 changed files with 104 additions and 52 deletions.
62 changes: 35 additions & 27 deletions docs/guides/accessibility.md
Expand Up @@ -35,6 +35,34 @@ using `aria-label`.
</TouchableOpacity>
```

### accessibilityLiveRegion

When components dynamically change we may need to inform the user. The
`accessibilityLiveRegion` property serves this purpose and can be set to
`none`, `polite` and `assertive`. On web, `accessibilityLiveRegion` is
implemented using `aria-live`.

* `none`: Accessibility services should not announce changes to this view.
* `polite`: Accessibility services should announce changes to this view.
* `assertive`: Accessibility services should interrupt ongoing speech to immediately announce changes to this view.

```
<TouchableWithoutFeedback onPress={this._addOne}>
<View style={styles.embedded}>
<Text>Click me</Text>
</View>
</TouchableWithoutFeedback>
<Text accessibilityLiveRegion="polite">
Clicked {this.state.count} times
</Text>
```

In the above example, method `_addOne` changes the `state.count` variable. As
soon as an end user clicks the `TouchableWithoutFeedback`, screen readers
announce text in the `Text` view because of its
`accessibilityLiveRegion="polite"` property.

### accessibilityRole

In some cases, we also want to alert the end user of the type of selected
Expand All @@ -49,7 +77,8 @@ element][html-aria-url] and ARIA `role`, where possible. In most cases, both
the element and ARIA `role` are rendered. While this may contradict some ARIA
recommendations, it also helps avoid certain browser bugs, HTML5 conformance
errors, and accessibility anti-patterns (e.g., giving a `heading` role to a
`button` element).
`button` element). On the Web, `accessibilityRole` supports more values than
React Native does for [Andriod and iOS](https://facebook.github.io/react-native/docs/accessibility#accessibilityrole-ios-android).

Straight-forward examples:

Expand Down Expand Up @@ -85,33 +114,12 @@ Note: Avoid changing `accessibilityRole` values over time or after user
actions. Generally, accessibility APIs do not provide a means of notifying
assistive technologies of a `role` value change.

### accessibilityLiveRegion

When components dynamically change we may need to inform the user. The
`accessibilityLiveRegion` property serves this purpose and can be set to
`none`, `polite` and `assertive`. On web, `accessibilityLiveRegion` is
implemented using `aria-live`.

* `none`: Accessibility services should not announce changes to this view.
* `polite`: Accessibility services should announce changes to this view.
* `assertive`: Accessibility services should interrupt ongoing speech to immediately announce changes to this view.
### accessibilityStates

```
<TouchableWithoutFeedback onPress={this._addOne}>
<View style={styles.embedded}>
<Text>Click me</Text>
</View>
</TouchableWithoutFeedback>
<Text accessibilityLiveRegion="polite">
Clicked {this.state.count} times
</Text>
```

In the above example, method `_addOne` changes the `state.count` variable. As
soon as an end user clicks the `TouchableWithoutFeedback`, screen readers
announce text in the `Text` view because of its
`accessibilityLiveRegion="polite"` property.
The `accessibilityStates` prop is an array of values used to infer the
analogous ARIA states, e.g., `aria-disabled`, `aria-pressed`, `aria-selected`.
On the Web, `accessibilityStates` supports more values than React Native does
for [Andriod and iOS](https://facebook.github.io/react-native/docs/accessibility#accessibilitystate-ios-android).

### importantForAccessibility

Expand Down
14 changes: 13 additions & 1 deletion packages/react-native-web/src/exports/View/ViewPropTypes.js
Expand Up @@ -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);

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
@@ -1,8 +1,9 @@
const whitelist = {
const supportedProps = {
accessibilityComponentType: true,
accessibilityLabel: true,
accessibilityLiveRegion: true,
accessibilityRole: true,
accessibilityStates: true,
accessibilityTraits: true,
accessible: true,
children: true,
Expand Down Expand Up @@ -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];
}
}
Expand Down
Expand Up @@ -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);
});
});
Expand Up @@ -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;
Expand Up @@ -15,7 +15,6 @@ const roleComponents = {
complementary: 'aside',
contentinfo: 'footer',
form: 'form',
label: 'label',
link: 'a',
list: 'ul',
listitem: 'li',
Expand All @@ -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') {
Expand Down
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Expand Up @@ -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"', () => {
Expand Down Expand Up @@ -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"', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Expand Up @@ -76,6 +76,7 @@ const createDOMProps = (component, props, styleResolver) => {
const {
accessibilityLabel,
accessibilityLiveRegion,
accessibilityStates,
importantForAccessibility,
nativeID,
placeholderTextColor,
Expand Down Expand Up @@ -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;
}

Expand Down

0 comments on commit 4040151

Please sign in to comment.