mirror of
https://github.com/zoriya/react-native-web.git
synced 2026-08-15 18:45:06 +00:00
[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:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
### accessibilityStates
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
+13
-1
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+5
-1
@@ -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') {
|
||||
|
||||
+20
-1
@@ -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;
|
||||
|
||||
+11
-18
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user