`. -Other ARIA properties should be set via [direct -manipulation](./direct-manipulation.md). +In the example below, the `TouchableWithoutFeedback` is announced by screen +readers as a native Button. + +``` + + + Press me! + + +``` + +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. + +``` + + + Click me + + + + + Clicked {this.state.count} times + +``` + +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. + +### importantForAccessibility + +The `importantForAccessibility` property controls if a view appears in the +accessibility tree and if it is reported to accessibility services. On web, a +value of `no` will remove a focusable element from the tab flow, and a value of +`no-hide-descendants` will also hide the entire subtree from assistive +technologies (this is implemented using `aria-hidden`). + +### Other + +Other ARIA properties can be set via [direct +manipulation](./direct-manipulation.md) or props (this may change in the +future). [aria-in-html-url]: https://w3c.github.io/aria-in-html/ [html-accessibility-url]: http://www.html5accessibility.com/ diff --git a/src/components/Image/__tests__/__snapshots__/index-test.js.snap b/src/components/Image/__tests__/__snapshots__/index-test.js.snap index 9cdc320a..ee7786b2 100644 --- a/src/components/Image/__tests__/__snapshots__/index-test.js.snap +++ b/src/components/Image/__tests__/__snapshots__/index-test.js.snap @@ -15,7 +15,6 @@ exports[`components/Image prop "accessibilityLabel" 1`] = ` exports[`components/Image prop "accessible" 1`] = ` `; exports[`components/Text prop "onPress" 1`] = ` - `; exports[`components/Text prop "selectable" 2`] = ` - diff --git a/src/components/Text/index.js b/src/components/Text/index.js index 6612aa6d..cbf7f62f 100644 --- a/src/components/Text/index.js +++ b/src/components/Text/index.js @@ -1,3 +1,4 @@ +import AccessibilityUtil from '../../modules/AccessibilityUtil'; import applyLayout from '../../modules/applyLayout'; import applyNativeMethods from '../../modules/applyNativeMethods'; import BaseComponentPropTypes from '../../propTypes/BaseComponentPropTypes'; @@ -22,13 +23,21 @@ class Text extends Component { style: StyleSheetPropType(TextStylePropTypes) }; - static defaultProps = { - accessible: true, - selectable: true + static childContextTypes = { + isInAParentText: bool }; + static contextTypes = { + isInAParentText: bool + }; + + getChildContext() { + return { isInAParentText: true }; + } + render() { const { + dir, numberOfLines, onPress, selectable, @@ -46,22 +55,24 @@ class Text extends Component { } = this.props; if (onPress) { + otherProps.accessible = true; otherProps.onClick = onPress; otherProps.onKeyDown = this._createEnterHandler(onPress); - otherProps.tabIndex = 0; } + // allow browsers to automatically infer the language writing direction + otherProps.dir = dir !== undefined ? dir : 'auto'; otherProps.style = [ styles.initial, + AccessibilityUtil.propsToAriaRole(this.props) === 'button' && styles.buttonReset, + this.context.isInAParentText !== true && styles.preserveWhitespace, style, - !selectable && styles.notSelectable, + selectable === false && styles.notSelectable, numberOfLines === 1 && styles.singleLineStyle, onPress && styles.pressable ]; - // allow browsers to automatically infer the language writing direction - otherProps.dir = 'auto'; - return createDOMElement('span', otherProps); + return createDOMElement('div', otherProps); } _createEnterHandler(fn) { @@ -82,9 +93,16 @@ const styles = StyleSheet.create({ margin: 0, padding: 0, textDecorationLine: 'none', - whiteSpace: 'pre-wrap', wordWrap: 'break-word' }, + preserveWhitespace: { + whiteSpace: 'pre-wrap' + }, + // reset browser default button styles + buttonReset: { + backgroundColor: 'transparent', + textAlign: 'inherit' + }, notSelectable: { userSelect: 'none' }, diff --git a/src/components/Touchable/TouchableHighlight.js b/src/components/Touchable/TouchableHighlight.js index eedc8d53..a15fa0ef 100644 --- a/src/components/Touchable/TouchableHighlight.js +++ b/src/components/Touchable/TouchableHighlight.js @@ -232,12 +232,12 @@ var TouchableHighlight = createReactClass({ var ENTER = 13; if ((e.type === 'keypress' ? e.charCode : e.keyCode) === ENTER) { callback && callback(e); + e.stopPropagation(); } }, render: function() { const { - children, /* eslint-disable */ activeOpacity, onHideUnderlay, @@ -258,6 +258,7 @@ var TouchableHighlight = createReactClass({ return ( { this._onKeyEnter(e, this.touchableHandleActivePressIn); }} @@ -275,9 +276,8 @@ var TouchableHighlight = createReactClass({ onResponderTerminate={this.touchableHandleResponderTerminate} ref={UNDERLAY_REF} style={[styles.root, this.props.disabled && styles.disabled, this.state.underlayStyle]} - tabIndex={this.props.disabled ? null : '0'} > - {React.cloneElement(React.Children.only(children), { + {React.cloneElement(React.Children.only(this.props.children), { ref: CHILD_REF })} {Touchable.renderDebugView({ color: 'green', hitSlop: this.props.hitSlop })} diff --git a/src/components/Touchable/TouchableOpacity.js b/src/components/Touchable/TouchableOpacity.js index f8fdaed5..c825d640 100644 --- a/src/components/Touchable/TouchableOpacity.js +++ b/src/components/Touchable/TouchableOpacity.js @@ -156,12 +156,12 @@ var TouchableOpacity = createReactClass({ var ENTER = 13; if ((e.type === 'keypress' ? e.charCode : e.keyCode) === ENTER) { callback && callback(e); + e.stopPropagation(); } }, render: function() { const { - children, /* eslint-disable */ activeOpacity, focusedOpacity, @@ -180,6 +180,7 @@ var TouchableOpacity = createReactClass({ return ( { this._onKeyEnter(e, this.touchableHandleActivePressIn); @@ -196,9 +197,8 @@ var TouchableOpacity = createReactClass({ onResponderMove={this.touchableHandleResponderMove} onResponderRelease={this.touchableHandleResponderRelease} onResponderTerminate={this.touchableHandleResponderTerminate} - tabIndex={this.props.disabled ? null : '0'} > - {children} + {this.props.children} {Touchable.renderDebugView({ color: 'blue', hitSlop: this.props.hitSlop })} ); diff --git a/src/components/Touchable/TouchableWithoutFeedback.js b/src/components/Touchable/TouchableWithoutFeedback.js index 36e932c2..6fa70660 100644 --- a/src/components/Touchable/TouchableWithoutFeedback.js +++ b/src/components/Touchable/TouchableWithoutFeedback.js @@ -182,6 +182,7 @@ const TouchableWithoutFeedback = createReactClass({ : [styles.root, this.props.disabled && styles.disabled, child.props.style]; return (React: any).cloneElement(child, { ...other, + accessible: this.props.accessible !== false, onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, onResponderTerminationRequest: this.touchableHandleResponderTerminationRequest, onResponderGrant: this.touchableHandleResponderGrant, @@ -189,8 +190,7 @@ const TouchableWithoutFeedback = createReactClass({ onResponderRelease: this.touchableHandleResponderRelease, onResponderTerminate: this.touchableHandleResponderTerminate, style, - children, - tabIndex: this.props.disabled ? null : '0' + children }); } }); diff --git a/src/components/View/index.js b/src/components/View/index.js index 3129e418..8764a7b9 100644 --- a/src/components/View/index.js +++ b/src/components/View/index.js @@ -1,8 +1,8 @@ +import AccessibilityUtil from '../../modules/AccessibilityUtil'; import applyLayout from '../../modules/applyLayout'; import applyNativeMethods from '../../modules/applyNativeMethods'; import { bool } from 'prop-types'; import createDOMElement from '../../modules/createDOMElement'; -import getAccessibilityRole from '../../modules/getAccessibilityRole'; import StyleSheet from '../../apis/StyleSheet'; import ViewPropTypes from './ViewPropTypes'; import React, { Component } from 'react'; @@ -25,10 +25,6 @@ class View extends Component { static propTypes = ViewPropTypes; - static defaultProps = { - accessible: true - }; - static childContextTypes = { isInAButtonView: bool }; @@ -39,7 +35,7 @@ class View extends Component { getChildContext() { const isInAButtonView = - getAccessibilityRole(this.props) === 'button' || this.context.isInAButtonView; + AccessibilityUtil.propsToAriaRole(this.props) === 'button' || this.context.isInAButtonView; return isInAButtonView ? { isInAButtonView } : emptyObject; } @@ -58,7 +54,7 @@ class View extends Component { } = this.props; const { isInAButtonView } = this.context; - const isButton = getAccessibilityRole(this.props) === 'button'; + const isButton = AccessibilityUtil.propsToAriaRole(this.props) === 'button'; otherProps.style = [styles.initial, isButton && styles.buttonOnly, style]; diff --git a/src/modules/getAccessibilityRole/__tests__/index-test.js b/src/modules/AccessibilityUtil/__tests__/propsToAriaRole-test.js similarity index 54% rename from src/modules/getAccessibilityRole/__tests__/index-test.js rename to src/modules/AccessibilityUtil/__tests__/propsToAriaRole-test.js index 13360b5c..46a2fe33 100644 --- a/src/modules/getAccessibilityRole/__tests__/index-test.js +++ b/src/modules/AccessibilityUtil/__tests__/propsToAriaRole-test.js @@ -1,24 +1,24 @@ /* eslint-env jasmine, jest */ -import getAccessibilityRole from '..'; +import propsToAriaRole from '../propsToAriaRole'; -describe('modules/getAccessibilityRole', () => { +describe('modules/AccessibilityUtil/propsToAriaRole', () => { test('returns undefined when missing accessibility props', () => { - expect(getAccessibilityRole({})).toBeUndefined(); + expect(propsToAriaRole({})).toBeUndefined(); }); test('returns value of "accessibilityRole" when defined', () => { - expect(getAccessibilityRole({ accessibilityRole: 'banner' })).toEqual('banner'); + expect(propsToAriaRole({ accessibilityRole: 'banner' })).toEqual('banner'); }); test('returns "button" when iOS/Android accessibility prop equals "button"', () => { - expect(getAccessibilityRole({ accessibilityComponentType: 'button' })).toEqual('button'); - expect(getAccessibilityRole({ accessibilityTraits: 'button' })).toEqual('button'); + expect(propsToAriaRole({ accessibilityComponentType: 'button' })).toEqual('button'); + expect(propsToAriaRole({ accessibilityTraits: 'button' })).toEqual('button'); }); test('prioritizes "accessibilityRole" when defined', () => { expect( - getAccessibilityRole({ + propsToAriaRole({ accessibilityComponentType: 'button', accessibilityRole: 'link', accessibilityTraits: 'button' diff --git a/src/modules/AccessibilityUtil/__tests__/propsToTabIndex.js b/src/modules/AccessibilityUtil/__tests__/propsToTabIndex.js new file mode 100644 index 00000000..32bdf20e --- /dev/null +++ b/src/modules/AccessibilityUtil/__tests__/propsToTabIndex.js @@ -0,0 +1,74 @@ +/* eslint-env jasmine, jest */ + +import propsToTabIndex from '../propsToTabIndex'; + +describe('modules/AccessibilityUtil/propsToTabIndex', () => { + test('returns undefined when missing accessibility props', () => { + expect(propsToTabIndex({})).toBeUndefined(); + }); + + describe('with focusable accessibilityRole', () => { + test('returns "undefined" by default', () => { + expect(propsToTabIndex({ accessibilityRole: 'button' })).toBeUndefined(); + expect(propsToTabIndex({ accessibilityRole: 'link' })).toBeUndefined(); + }); + + test('returns "undefined" when "accessible" is true', () => { + expect(propsToTabIndex({ accessibilityRole: 'button', accessible: true })).toBeUndefined(); + }); + + test('returns "-1" when "accessible" is false', () => { + expect(propsToTabIndex({ accessibilityRole: 'button', accessible: false })).toEqual('-1'); + }); + + test('returns "-1" when "disabled" is true', () => { + expect(propsToTabIndex({ accessibilityRole: 'button', disabled: true })).toEqual('-1'); + }); + + test('returns "undefined" when "disabled" is false', () => { + expect(propsToTabIndex({ accessibilityRole: 'button', disabled: false })).toBeUndefined(); + }); + + test('returns "-1" when "importantForAccessibility" is "no"', () => { + expect( + propsToTabIndex({ accessibilityRole: 'button', importantForAccessibility: 'no' }) + ).toEqual('-1'); + }); + + test('returns "-1" when "importantForAccessibility" is "no-hide-descendants"', () => { + expect( + propsToTabIndex({ + accessibilityRole: 'button', + importantForAccessibility: 'no-hide-descendants' + }) + ).toEqual('-1'); + }); + }); + + describe('with unfocusable accessibilityRole', () => { + test('returns "undefined" by default', () => { + expect(propsToTabIndex({})).toBeUndefined(); + }); + + test('returns "0" when "accessible" is true', () => { + expect(propsToTabIndex({ accessible: true })).toEqual('0'); + }); + + test('returns "undefined" when "accessible" is false', () => { + expect(propsToTabIndex({ accessible: false })).toBeUndefined(); + }); + + test('returns "undefined" when "importantForAccessibility" is "no"', () => { + expect(propsToTabIndex({ importantForAccessibility: 'no' })).toBeUndefined(); + expect( + propsToTabIndex({ accessible: true, importantForAccessibility: 'no' }) + ).toBeUndefined(); + }); + + test('returns "undefined" when "importantForAccessibility" is "no-hide-descendants"', () => { + expect( + propsToTabIndex({ accessible: true, importantForAccessibility: 'no-hide-descendants' }) + ).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/AccessibilityUtil/index.js b/src/modules/AccessibilityUtil/index.js new file mode 100644 index 00000000..093f8666 --- /dev/null +++ b/src/modules/AccessibilityUtil/index.js @@ -0,0 +1,9 @@ +import propsToAccessibilityComponent from './propsToAccessibilityComponent'; +import propsToAriaRole from './propsToAriaRole'; +import propsToTabIndex from './propsToTabIndex'; + +module.exports = { + propsToAccessibilityComponent, + propsToAriaRole, + propsToTabIndex +}; diff --git a/src/modules/AccessibilityUtil/propsToAccessibilityComponent.js b/src/modules/AccessibilityUtil/propsToAccessibilityComponent.js new file mode 100644 index 00000000..32745fa3 --- /dev/null +++ b/src/modules/AccessibilityUtil/propsToAccessibilityComponent.js @@ -0,0 +1,29 @@ +import propsToAriaRole from './propsToAriaRole'; + +const roleComponents = { + article: 'article', + banner: 'header', + button: 'button', + complementary: 'aside', + contentinfo: 'footer', + form: 'form', + link: 'a', + list: 'ul', + listitem: 'li', + main: 'main', + navigation: 'nav', + region: 'section' +}; + +const emptyObject = {}; + +const propsToAccessibilityComponent = (props = emptyObject) => { + const role = propsToAriaRole(props); + if (role === 'heading') { + const level = props['aria-level'] || 1; + return `h${level}`; + } + return roleComponents[role]; +}; + +module.exports = propsToAccessibilityComponent; diff --git a/src/modules/AccessibilityUtil/propsToAriaRole.js b/src/modules/AccessibilityUtil/propsToAriaRole.js new file mode 100644 index 00000000..5aa92bf1 --- /dev/null +++ b/src/modules/AccessibilityUtil/propsToAriaRole.js @@ -0,0 +1,33 @@ +const accessibilityComponentTypeToRole = { + button: 'button', + none: 'presentation' +}; + +const accessibilityTraitsToRole = { + adjustable: 'slider', + button: 'button', + image: 'img', + link: 'link', + none: 'presentation', + search: 'search', + summary: 'region' +}; + +const propsToAriaRole = ({ + accessibilityComponentType, + accessibilityRole, + accessibilityTraits +}) => { + if (accessibilityRole) { + return accessibilityRole; + } + if (accessibilityTraits) { + const trait = Array.isArray(accessibilityTraits) ? accessibilityTraits[0] : accessibilityTraits; + return accessibilityTraitsToRole[trait]; + } + if (accessibilityComponentType) { + return accessibilityComponentTypeToRole[accessibilityComponentType]; + } +}; + +module.exports = propsToAriaRole; diff --git a/src/modules/AccessibilityUtil/propsToTabIndex.js b/src/modules/AccessibilityUtil/propsToTabIndex.js new file mode 100644 index 00000000..8cb523d4 --- /dev/null +++ b/src/modules/AccessibilityUtil/propsToTabIndex.js @@ -0,0 +1,22 @@ +import propsToAriaRole from './propsToAriaRole'; + +const propsToTabIndex = props => { + const ariaRole = propsToAriaRole(props); + const focusable = + props.disabled !== true && + props.importantForAccessibility !== 'no' && + props.importantForAccessibility !== 'no-hide-descendants'; + const focusableRole = ariaRole === 'button' || ariaRole === 'link'; + + if (focusableRole) { + if (props.accessible === false || !focusable) { + return '-1'; + } + } else { + if (props.accessible === true && focusable) { + return '0'; + } + } +}; + +module.exports = propsToTabIndex; diff --git a/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap b/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap index 58b50083..8da2a6f7 100644 --- a/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap +++ b/src/modules/createDOMElement/__tests__/__snapshots__/index-test.js.snap @@ -72,16 +72,26 @@ exports[`modules/createDOMElement prop "accessibilityRole" roles 1`] = ` /> `; -exports[`modules/createDOMElement prop "accessible" 1`] = ``; +exports[`modules/createDOMElement prop "accessible" 1`] = ` + +`; exports[`modules/createDOMElement prop "accessible" 2`] = ``; -exports[`modules/createDOMElement prop "accessible" 3`] = ` +exports[`modules/createDOMElement prop "importantForAccessibility" 1`] = ``; + +exports[`modules/createDOMElement prop "importantForAccessibility" 2`] = ``; + +exports[`modules/createDOMElement prop "importantForAccessibility" 3`] = `