[change] a11y and layout: button role and DOM props

Problems:

HTML's native <button> tag doesn't support flex styling in all browsers,
causing layout bugs. And buttons or links created by "createDOMElement"
(without an accessibility role) do not have the correct props.

Solution:

The "button" role is rendered to a "div[role=button]" that is focusable
and responds to the same keyboard events as a native button. A native
button can still be rendered using "createDOMElement".

Make "createDOMProps" aware of the component context to ensure style
resets and other props are correctly applied when an accessibility role
is not defined.

Additionally:

This patch also adds a new "label" role to support accessible forms.
It maps to a native label element.

Close #241
This commit is contained in:
Nicolas Gallagher
2017-07-07 23:34:40 -07:00
parent 1a0a40d9be
commit ad3dee0204
24 changed files with 425 additions and 378 deletions

View File

@@ -40,35 +40,40 @@ using `aria-label`.
In some cases, we also want to alert the end user of the type of selected
component (i.e., that it is a “button”). To provide more context to screen
readers, you should specify the `accessibilityRole` property. (Note that React
Native for Web provides a compatibility mapping of equivalent
Native for Web also provides a compatibility mapping of equivalent
`accessibilityTraits` and `accessibilityComponentType` values to
`accessibilityRole`).
The `accessibilityRole` prop is used to infer an [analogous HTML
element][html-aria-url] to use in addition to the resulting ARIA `role`, where
possible. While this may contradict some ARIA recommendations, it also helps
avoid certain HTML5 conformance errors and accessibility anti-patterns (e.g.,
giving a `heading` role to a `button` element).
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 HTML5 conformance errors and
accessibility anti-patterns (e.g., giving a `heading` role to a `button`
element) and browser bugs.
For example:
* `<View accessibilityRole='article' />` => `<article role='article' />`.
* `<View accessibilityRole='banner' />` => `<header role='banner' />`.
* `<View accessibilityRole='button' />` => `<button type='button' role='button' />`.
* `<View accessibilityRole='button' />` => `<div role='button' tabIndex='0' />`.
* `<Text accessibilityRole='label' />` => `<label />`.
* `<Text accessibilityRole='link' href='/' />` => `<a role='link' href='/' />`.
* `<View accessibilityRole='main' />` => `<main role='main' />`.
In the example below, the `TouchableWithoutFeedback` is announced by screen
readers as a native Button.
In the example below, the `TouchableHighlight` is announced by screen
readers as a button.
```
<TouchableWithoutFeedback accessibilityRole="button" onPress={this._onPress}>
```js
<TouchableHighlight accessibilityRole="button" onPress={this._handlePress}>
<View style={styles.button}>
<Text style={styles.buttonText}>Press me!</Text>
</View>
</TouchableWithoutFeedback>
</TouchableHighlight>
```
Note: The `button` role is not implemented using the native `button` element
due to browsers limiting the use of flexbox layout on its children.
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.

View File

@@ -30,6 +30,7 @@ exports[`apis/AppRegistry/renderApplication getApplication 3`] = `
.rn-position-bnwqim{position:relative}
.rn-right-zchlnj{right:0px}
.rn-top-ipm5af{top:0px}
.rn-cursor-1loqt21{cursor:pointer}
.rn-appearance-30o5oe{-moz-appearance:none;-webkit-appearance:none;appearance:none}
.rn-backgroundColor-wib322{background-color:transparent}
.rn-color-homxoj{color:inherit}

View File

@@ -66,7 +66,7 @@ export default class StyleRegistry {
*/
resolve(reactNativeStyle, options = emptyObject) {
if (!reactNativeStyle) {
return undefined;
return emptyObject;
}
// fast and cachable

View File

@@ -34,6 +34,7 @@ exports[`components/Switch disabled when "true" a disabled checkbox is rendered
style="height:20px;width:20px;"
/>
<input
aria-disabled="true"
class="rn-bottom-1p0dtai rn-cursor-1ei5mc7 rn-height-1pi2tsx rn-left-1d2f490 rn-marginTop-1mnahxq rn-marginRight-61z16t rn-marginBottom-p1pxzi rn-marginLeft-11wrixw rn-opacity-1272l3b rn-paddingTop-wk8lta rn-paddingRight-9aemit rn-paddingBottom-1mdbw0j rn-paddingLeft-gy4na3 rn-position-u8s1d rn-right-zchlnj rn-top-ipm5af rn-width-13qz1uu"
disabled=""
type="checkbox"

View File

@@ -192,7 +192,7 @@ describe('components/TextInput', () => {
}
});
test('modifier keys', done => {
test('modifier keys are included', done => {
const input = findNativeInput(mount(<TextInput onKeyPress={onKeyPress} />));
input.simulate('keyPress', {
altKey: true,

View File

@@ -10,11 +10,6 @@ describe('components/View', () => {
const component = shallow(<View />);
expect(component.type()).toBe('div');
});
test('is a "span" when inside <View accessibilityRole="button" />', () => {
const component = render(<View accessibilityRole="button"><View /></View>);
expect(component.find('span').length).toEqual(1);
});
});
test('prop "children"', () => {

View File

@@ -7,7 +7,6 @@
* @flow
*/
import AccessibilityUtil from '../../modules/AccessibilityUtil';
import applyLayout from '../../modules/applyLayout';
import applyNativeMethods from '../../modules/applyNativeMethods';
import { bool } from 'prop-types';
@@ -16,8 +15,6 @@ import StyleSheet from '../../apis/StyleSheet';
import ViewPropTypes from './ViewPropTypes';
import React, { Component } from 'react';
const emptyObject = {};
const calculateHitSlopStyle = hitSlop => {
const hitStyle = {};
for (const prop in hitSlop) {
@@ -32,23 +29,12 @@ const calculateHitSlopStyle = hitSlop => {
class View extends Component {
static displayName = 'View';
static childContextTypes = {
isInAButtonView: bool
};
static contextTypes = {
isInAButtonView: bool,
isInAParentText: bool
};
static propTypes = ViewPropTypes;
getChildContext() {
const isInAButtonView =
AccessibilityUtil.propsToAriaRole(this.props) === 'button' || this.context.isInAButtonView;
return isInAButtonView ? { isInAButtonView } : emptyObject;
}
render() {
const {
hitSlop,
@@ -63,7 +49,7 @@ class View extends Component {
...otherProps
} = this.props;
const { isInAButtonView, isInAParentText } = this.context;
const { isInAParentText } = this.context;
otherProps.style = [styles.initial, isInAParentText && styles.inline, style];
@@ -76,7 +62,7 @@ class View extends Component {
}
// avoid HTML validation errors
const component = isInAButtonView ? 'span' : 'div';
const component = 'div';
return createDOMElement(component, otherProps);
}

View File

@@ -0,0 +1,27 @@
/* eslint-env jasmine, jest */
import propsToAccessibilityComponent from '../propsToAccessibilityComponent';
describe('modules/AccessibilityUtil/propsToAccessibilityComponent', () => {
test('when missing accessibility props"', () => {
expect(propsToAccessibilityComponent({})).toBeUndefined();
});
test('when "accessibilityRole" is "button"', () => {
expect(propsToAccessibilityComponent({ accessibilityRole: 'button' })).toBeUndefined();
});
test('when "accessibilityRole" is "heading"', () => {
expect(propsToAccessibilityComponent({ accessibilityRole: 'heading' })).toEqual('h1');
});
test('when "accessibilityRole" is "heading" and "aria-level" is set', () => {
expect(
propsToAccessibilityComponent({ accessibilityRole: 'heading', 'aria-level': 3 })
).toEqual('h3');
});
test('when "accessibilityRole" is "label"', () => {
expect(propsToAccessibilityComponent({ accessibilityRole: 'label' })).toEqual('label');
});
});

View File

@@ -3,15 +3,15 @@
import propsToAriaRole from '../propsToAriaRole';
describe('modules/AccessibilityUtil/propsToAriaRole', () => {
test('returns undefined when missing accessibility props', () => {
test('when missing accessibility props', () => {
expect(propsToAriaRole({})).toBeUndefined();
});
test('returns value of "accessibilityRole" when defined', () => {
test('when "accessibilityRole" is defined', () => {
expect(propsToAriaRole({ accessibilityRole: 'banner' })).toEqual('banner');
});
test('returns "button" when iOS/Android accessibility prop equals "button"', () => {
test('when iOS/Android accessibility prop equals "button"', () => {
expect(propsToAriaRole({ accessibilityComponentType: 'button' })).toEqual('button');
expect(propsToAriaRole({ accessibilityTraits: 'button' })).toEqual('button');
});

View File

@@ -0,0 +1,116 @@
/* eslint-env jasmine, jest */
import propsToTabIndex from '../propsToTabIndex';
describe('modules/AccessibilityUtil/propsToTabIndex', () => {
test('with no accessibility props', () => {
expect(propsToTabIndex({})).toBeUndefined();
});
describe('"accessibilityRole" of "link"', () => {
const accessibilityRole = 'link';
test('default case', () => {
expect(propsToTabIndex({ accessibilityRole })).toBeUndefined();
});
test('when "accessible" is true', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: true })).toBeUndefined();
});
test('when "accessible" is false', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: false })).toEqual('-1');
});
test('when "disabled" is true', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: true })).toEqual('-1');
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': true })).toEqual('-1');
});
test('when "disabled" is false', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: false })).toBeUndefined();
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': false })).toBeUndefined();
});
test('when "importantForAccessibility" is "no"', () => {
expect(propsToTabIndex({ accessibilityRole, importantForAccessibility: 'no' })).toEqual('-1');
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
propsToTabIndex({
accessibilityRole,
importantForAccessibility: 'no-hide-descendants'
})
).toEqual('-1');
});
});
describe('"accessibilityRole" of "button"', () => {
const accessibilityRole = 'button';
test('default case', () => {
expect(propsToTabIndex({ accessibilityRole })).toEqual('0');
});
test('when "accessible" is true', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: true })).toEqual('0');
});
test('when "accessible" is false', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: false })).toBeUndefined();
});
test('when "disabled" is true', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: true })).toBeUndefined();
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': true })).toBeUndefined();
});
test('when "disabled" is false', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: false })).toEqual('0');
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': false })).toEqual('0');
});
test('when "importantForAccessibility" is "no"', () => {
expect(
propsToTabIndex({ accessibilityRole, importantForAccessibility: 'no' })
).toBeUndefined();
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
propsToTabIndex({
accessibilityRole,
importantForAccessibility: 'no-hide-descendants'
})
).toBeUndefined();
});
});
describe('with unfocusable accessibilityRole', () => {
test('default case', () => {
expect(propsToTabIndex({})).toBeUndefined();
});
test('when "accessible" is true', () => {
expect(propsToTabIndex({ accessible: true })).toEqual('0');
});
test('when "accessible" is false', () => {
expect(propsToTabIndex({ accessible: false })).toBeUndefined();
});
test('when "importantForAccessibility" is "no"', () => {
expect(propsToTabIndex({ importantForAccessibility: 'no' })).toBeUndefined();
expect(
propsToTabIndex({ accessible: true, importantForAccessibility: 'no' })
).toBeUndefined();
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
propsToTabIndex({ accessible: true, importantForAccessibility: 'no-hide-descendants' })
).toBeUndefined();
});
});
});

View File

@@ -1,74 +0,0 @@
/* 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();
});
});
});

View File

@@ -8,11 +8,13 @@
* @flow
*/
import isDisabled from './isDisabled';
import propsToAccessibilityComponent from './propsToAccessibilityComponent';
import propsToAriaRole from './propsToAriaRole';
import propsToTabIndex from './propsToTabIndex';
const AccessibilityUtil = {
isDisabled,
propsToAccessibilityComponent,
propsToAriaRole,
propsToTabIndex

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2017-present, Nicolas Gallagher.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const isDisabled = (props: Object) => props.disabled || props['aria-disabled'];
export default isDisabled;

View File

@@ -5,7 +5,7 @@
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*
* @noflow
* @flow
*/
import propsToAriaRole from './propsToAriaRole';
@@ -13,10 +13,10 @@ import propsToAriaRole from './propsToAriaRole';
const roleComponents = {
article: 'article',
banner: 'header',
button: 'button',
complementary: 'aside',
contentinfo: 'footer',
form: 'form',
label: 'label',
link: 'a',
list: 'ul',
listitem: 'li',
@@ -27,13 +27,15 @@ const roleComponents = {
const emptyObject = {};
const propsToAccessibilityComponent = (props = emptyObject) => {
const propsToAccessibilityComponent = (props: Object = emptyObject) => {
const role = propsToAriaRole(props);
if (role === 'heading') {
const level = props['aria-level'] || 1;
return `h${level}`;
if (role) {
if (role === 'heading') {
const level = props['aria-level'] || 1;
return `h${level}`;
}
return roleComponents[role];
}
return roleComponents[role];
};
export default propsToAccessibilityComponent;

View File

@@ -5,7 +5,7 @@
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*
* @noflow
* @flow
*/
const accessibilityComponentTypeToRole = {
@@ -24,11 +24,16 @@ const accessibilityTraitsToRole = {
summary: 'region'
};
/**
* Provides compatibility with React Native's "accessibilityTraits" (iOS) and
* "accessibilityComponentType" (Android), converting them to equivalent ARIA
* roles.
*/
const propsToAriaRole = ({
accessibilityComponentType,
accessibilityRole,
accessibilityTraits
}) => {
}: Object) => {
if (accessibilityRole) {
return accessibilityRole;
}

View File

@@ -5,23 +5,29 @@
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*
* @noflow
* @flow
*/
import isDisabled from './isDisabled';
import propsToAriaRole from './propsToAriaRole';
const propsToTabIndex = props => {
const ariaRole = propsToAriaRole(props);
const propsToTabIndex = (props: Object) => {
const role = propsToAriaRole(props);
const focusable =
props.disabled !== true &&
!isDisabled(props) &&
props.importantForAccessibility !== 'no' &&
props.importantForAccessibility !== 'no-hide-descendants';
const focusableRole = ariaRole === 'button' || ariaRole === 'link';
if (focusableRole) {
// Assume that 'link' is focusable by default (uses <a>).
// Assume that 'button' is not (uses <div role='button'>) but must be treated as such.
if (role === 'link') {
if (props.accessible === false || !focusable) {
return '-1';
}
} else if (role === 'button') {
if (props.accessible !== false && focusable) {
return '0';
}
} else {
if (props.accessible === true && focusable) {
return '0';

View File

@@ -120,7 +120,7 @@ const NativeMethodsMixin = {
const domStyleProps = { classList, style };
// Next DOM state
const domProps = createDOMProps(i18nStyle(nativeProps), style =>
const domProps = createDOMProps(null, i18nStyle(nativeProps), style =>
StyleRegistry.resolveStateful(style, domStyleProps, { i18n: false })
);
UIManager.updateView(node, domProps, this);

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`modules/createDOMElement onClick 1`] = `
exports[`modules/createDOMElement it normalizes event.nativeEvent 1`] = `
Object {
"_normalized": true,
"changedTouches": Array [],
@@ -15,110 +15,6 @@ Object {
}
`;
exports[`modules/createDOMElement prop "accessibilityLabel" 1`] = `
<span
aria-label="accessibilityLabel"
/>
`;
exports[`modules/createDOMElement it renders different DOM elements 1`] = `<span />`;
exports[`modules/createDOMElement prop "accessibilityLiveRegion" 1`] = `
<span
aria-live="off"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" button 1`] = `
<button
class="rn-appearance-30o5oe rn-backgroundColor-wib322 rn-color-homxoj rn-font-1lw9tu2 rn-textAlign-1ttztb7"
role="button"
type="button"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" compatibility with accessibilityComponentType 1`] = `
<button
class="rn-appearance-30o5oe rn-backgroundColor-wib322 rn-color-homxoj rn-font-1lw9tu2 rn-textAlign-1ttztb7"
role="button"
type="button"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" compatibility with accessibilityComponentType 2`] = `
<a
class="rn-backgroundColor-wib322 rn-color-homxoj rn-textDecoration-bauka4"
role="link"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" compatibility with accessibilityTraits 1`] = `
<button
class="rn-appearance-30o5oe rn-backgroundColor-wib322 rn-color-homxoj rn-font-1lw9tu2 rn-textAlign-1ttztb7"
role="button"
type="button"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" compatibility with accessibilityTraits 2`] = `
<a
class="rn-backgroundColor-wib322 rn-color-homxoj rn-textDecoration-bauka4"
role="link"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" headings 1`] = `
<h1
role="heading"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" headings 2`] = `
<h3
aria-level="3"
role="heading"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" link and target="_blank" 1`] = `
<a
class="rn-backgroundColor-wib322 rn-color-homxoj rn-textDecoration-bauka4"
rel=" noopener noreferrer"
role="link"
target="_blank"
/>
`;
exports[`modules/createDOMElement prop "accessibilityRole" roles 1`] = `
<header
role="banner"
/>
`;
exports[`modules/createDOMElement prop "accessible" 1`] = `
<span
tabindex="0"
/>
`;
exports[`modules/createDOMElement prop "accessible" 2`] = `<span />`;
exports[`modules/createDOMElement prop "importantForAccessibility" 1`] = `<span />`;
exports[`modules/createDOMElement prop "importantForAccessibility" 2`] = `<span />`;
exports[`modules/createDOMElement prop "importantForAccessibility" 3`] = `
<span
aria-hidden="true"
/>
`;
exports[`modules/createDOMElement prop "importantForAccessibility" 4`] = `<span />`;
exports[`modules/createDOMElement prop "testID" 1`] = `
<span
data-testid="Example.testID"
/>
`;
exports[`modules/createDOMElement renders correct DOM element 1`] = `<span />`;
exports[`modules/createDOMElement renders correct DOM element 2`] = `<main />`;
exports[`modules/createDOMElement it renders different DOM elements 2`] = `<main />`;

View File

@@ -4,106 +4,14 @@ import createDOMElement from '..';
import { shallow, render } from 'enzyme';
describe('modules/createDOMElement', () => {
test('renders correct DOM element', () => {
test('it renders different DOM elements', () => {
let component = render(createDOMElement('span'));
expect(component).toMatchSnapshot();
component = render(createDOMElement('main'));
expect(component).toMatchSnapshot();
});
test('prop "accessibilityLabel"', () => {
const accessibilityLabel = 'accessibilityLabel';
const component = render(createDOMElement('span', { accessibilityLabel }));
expect(component).toMatchSnapshot();
});
test('prop "accessibilityLiveRegion"', () => {
const component = render(createDOMElement('span', { accessibilityLiveRegion: 'none' }));
expect(component).toMatchSnapshot();
});
describe('prop "accessibilityRole"', () => {
test('roles', () => {
const component = render(createDOMElement('span', { accessibilityRole: 'banner' }));
expect(component).toMatchSnapshot();
});
test('button', () => {
const component = render(createDOMElement('span', { accessibilityRole: 'button' }));
expect(component).toMatchSnapshot();
});
test('headings', () => {
let component = render(createDOMElement('div', { accessibilityRole: 'heading' }));
expect(component).toMatchSnapshot();
component = render(
createDOMElement('div', { accessibilityRole: 'heading', 'aria-level': '3' })
);
expect(component).toMatchSnapshot();
});
test('link and target="_blank"', () => {
const component = render(
createDOMElement('span', {
accessibilityRole: 'link',
target: '_blank'
})
);
expect(component).toMatchSnapshot();
});
describe('compatibility with', () => {
test('accessibilityComponentType', () => {
let component = render(createDOMElement('span', { accessibilityComponentType: 'button' }));
expect(component).toMatchSnapshot();
component = render(
createDOMElement('span', {
accessibilityComponentType: 'button',
accessibilityRole: 'link'
})
);
expect(component).toMatchSnapshot();
});
test('accessibilityTraits', () => {
let component = render(createDOMElement('span', { accessibilityTraits: 'button' }));
expect(component).toMatchSnapshot();
component = render(
createDOMElement('span', { accessibilityTraits: 'button', accessibilityRole: 'link' })
);
expect(component).toMatchSnapshot();
});
});
});
test('prop "accessible"', () => {
let component = render(createDOMElement('span', { accessible: true }));
expect(component).toMatchSnapshot();
component = render(createDOMElement('span', { accessible: false }));
expect(component).toMatchSnapshot();
});
test('prop "importantForAccessibility"', () => {
let component = render(createDOMElement('span', { importantForAccessibility: 'auto' }));
expect(component).toMatchSnapshot();
component = render(createDOMElement('span', { importantForAccessibility: 'no' }));
expect(component).toMatchSnapshot();
component = render(
createDOMElement('span', { importantForAccessibility: 'no-hide-descendants' })
);
expect(component).toMatchSnapshot();
component = render(createDOMElement('span', { importantForAccessibility: 'yes' }));
expect(component).toMatchSnapshot();
});
test('onClick', done => {
test('it normalizes event.nativeEvent', done => {
const onClick = e => {
e.nativeEvent.timestamp = 1496876171255;
expect(e.nativeEvent).toMatchSnapshot();
@@ -119,8 +27,29 @@ describe('modules/createDOMElement', () => {
});
});
test('prop "testID"', () => {
const component = render(createDOMElement('span', { testID: 'Example.testID' }));
expect(component).toMatchSnapshot();
describe('when ARIA role is "button"', () => {
[{ disabled: true }, { disabled: false }].forEach(({ disabled }) => {
describe(`and disabled is "${disabled}"`, () => {
[{ name: 'Enter', which: 13 }, { name: 'Space', which: 32 }].forEach(({ name, which }) => {
test(`"onClick" is ${disabled ? 'not ' : ''}called when "${name}" is pressed`, () => {
const onClick = jest.fn();
const component = shallow(
createDOMElement('span', { accessibilityRole: 'button', disabled, onClick })
);
component.find('span').simulate('keyPress', {
isDefaultPrevented() {},
nativeEvent: {
preventDefault() {},
stopImmediatePropagation() {},
stopPropagation() {}
},
preventDefault() {},
which
});
expect(onClick).toHaveBeenCalledTimes(disabled ? 0 : 1);
});
});
});
});
});
});

View File

@@ -18,6 +18,12 @@ import React from 'react';
modality();
/**
* Ensure event handlers receive an event of the expected shape. The 'button'
* role for accessibility reasons and functional equivalence to the native
* button element must also support synthetic keyboard activation of onclick,
* and remove event handlers when disabled.
*/
const eventHandlerNames = {
onClick: true,
onClickCapture: true,
@@ -40,27 +46,47 @@ const eventHandlerNames = {
onTouchStart: true,
onTouchStartCapture: true
};
const adjustProps = domProps => {
const isButtonRole = domProps.role === 'button';
const isDisabled = AccessibilityUtil.isDisabled(domProps);
const wrapEventHandler = handler => e => {
e.nativeEvent = normalizeNativeEvent(e.nativeEvent);
return handler(e);
Object.keys(domProps).forEach(propName => {
const prop = domProps[propName];
const isEventHandler = typeof prop === 'function' && eventHandlerNames[propName];
if (isEventHandler) {
if (isButtonRole && isDisabled) {
domProps[propName] = undefined;
} else {
// TODO: move this out of the render path
domProps[propName] = e => {
e.nativeEvent = normalizeNativeEvent(e.nativeEvent);
return prop(e);
};
}
}
});
// Button role should trigger 'onClick' if SPACE or ENTER keys are pressed
if (isButtonRole && !isDisabled) {
const { onClick } = domProps;
domProps.onKeyPress = function(e) {
if (!e.isDefaultPrevented() && (e.which === 13 || e.which === 32)) {
e.preventDefault();
if (onClick) {
onClick(e);
}
}
};
}
};
const createDOMElement = (component, props) => {
// use equivalent platform elements where possible
const accessibilityComponent = AccessibilityUtil.propsToAccessibilityComponent(props);
const Component = accessibilityComponent || component;
const domProps = createDOMProps(props);
const domProps = createDOMProps(Component, props);
// normalize DOM events to match React Native events
// TODO: move this out of the render path
Object.keys(domProps).forEach(propName => {
const prop = domProps[propName];
const isEventHandler = typeof prop === 'function' && eventHandlerNames[propName];
if (isEventHandler) {
domProps[propName] = wrapEventHandler(prop);
}
});
adjustProps(domProps);
return <Component {...domProps} />;
};

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`modules/createDOMProps includes "rel" values for "a" elements (to securely open external links) 1`] = `" noopener noreferrer"`;
exports[`modules/createDOMProps includes cursor style for "button" role 1`] = `
Object {
"className": "rn-cursor-1loqt21",
"role": "button",
"tabIndex": "0",
}
`;
exports[`modules/createDOMProps includes reset styles for "a" elements 1`] = `
Object {
"className": "rn-backgroundColor-wib322 rn-color-homxoj rn-textDecoration-bauka4",
}
`;
exports[`modules/createDOMProps includes reset styles for "button" elements 1`] = `
Object {
"className": "rn-appearance-30o5oe rn-backgroundColor-wib322 rn-color-homxoj rn-font-1lw9tu2 rn-textAlign-1ttztb7",
}
`;
exports[`modules/createDOMProps includes reset styles for "ul" elements 1`] = `
Object {
"className": "rn-listStyle-1ebb2ja",
}
`;

View File

@@ -0,0 +1,74 @@
/* eslint-env jasmine, jest */
import createDOMProps from '..';
const createProps = props => createDOMProps(null, props);
describe('modules/createDOMProps', () => {
test('prop "accessibilityLabel" becomes "aria-label"', () => {
const accessibilityLabel = 'accessibilityLabel';
const props = createProps({ accessibilityLabel });
expect(props['aria-label']).toEqual(accessibilityLabel);
});
test('prop "accessibilityLiveRegion" becomes "aria-live"', () => {
const accessibilityLiveRegion = 'none';
const props = createProps({ accessibilityLiveRegion });
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 "className" is preserved', () => {
const className = 'external-class-name';
const props = createProps({ className });
expect(props.className).toEqual(className);
});
test('prop "importantForAccessibility" becomes "aria-hidden"', () => {
const props = createProps({ importantForAccessibility: 'no-hide-descendants' });
expect(props['aria-hidden']).toEqual(true);
});
test('prop "testID" becomes "data-testid"', () => {
const testID = 'Example.testID';
const props = createProps({ testID });
expect(props['data-testid']).toEqual(testID);
});
test('includes reset styles for "a" elements', () => {
const props = createDOMProps('a');
expect(props).toMatchSnapshot();
});
test('includes "rel" values for "a" elements (to securely open external links)', () => {
const props = createDOMProps('a', { target: '_blank' });
expect(props.rel).toMatchSnapshot();
});
test('includes reset styles for "button" elements', () => {
const props = createDOMProps('button');
expect(props).toMatchSnapshot();
});
test('includes cursor style for "button" role', () => {
const props = createDOMProps('span', { accessibilityRole: 'button' });
expect(props).toMatchSnapshot();
});
test('includes reset styles for "ul" elements', () => {
const props = createDOMProps('ul');
expect(props).toMatchSnapshot();
});
test('includes tabIndex when needed', () => {
const props = createProps({ accessible: true });
expect(props.tabIndex).toBeDefined();
});
});

View File

@@ -14,25 +14,28 @@ import StyleRegistry from '../../apis/StyleSheet/registry';
const emptyObject = {};
const styles = StyleSheet.create({
buttonReset: {
const resetStyles = StyleSheet.create({
ariaButton: {
cursor: 'pointer'
},
button: {
appearance: 'none',
backgroundColor: 'transparent',
color: 'inherit',
font: 'inherit',
textAlign: 'inherit'
},
linkReset: {
link: {
backgroundColor: 'transparent',
color: 'inherit',
textDecorationLine: 'none'
},
listReset: {
list: {
listStyle: 'none'
}
});
const pointerEventStyles = StyleSheet.create({
const pointerEventsStyles = StyleSheet.create({
auto: {
pointerEvents: 'auto'
},
@@ -47,24 +50,26 @@ const pointerEventStyles = StyleSheet.create({
}
});
const resolver = style => StyleRegistry.resolve(style);
const defaultStyleResolver = style => StyleRegistry.resolve(style);
const createDOMProps = (rnProps, resolveStyle) => {
if (!resolveStyle) {
resolveStyle = resolver;
const createDOMProps = (component, props, styleResolver) => {
if (!styleResolver) {
styleResolver = defaultStyleResolver;
}
if (!props) {
props = emptyObject;
}
const props = rnProps || emptyObject;
const {
accessibilityLabel,
accessibilityLiveRegion,
accessible,
importantForAccessibility,
pointerEvents,
style: rnStyle,
style: providedStyle,
testID,
type,
/* eslint-disable */
accessible,
accessibilityComponentType,
accessibilityRole,
accessibilityTraits,
@@ -72,19 +77,24 @@ const createDOMProps = (rnProps, resolveStyle) => {
...domProps
} = props;
const isDisabled = AccessibilityUtil.isDisabled(props);
const role = AccessibilityUtil.propsToAriaRole(props);
const pointerEventStyle = pointerEvents !== undefined && pointerEventStyles[pointerEvents];
const tabIndex = AccessibilityUtil.propsToTabIndex(props);
const reactNativeStyle = [
(role === 'button' && styles.buttonReset) ||
(role === 'link' && styles.linkReset) ||
(role === 'list' && styles.listReset),
rnStyle,
pointerEventStyle
component === 'a' && resetStyles.link,
component === 'button' && resetStyles.button,
component === 'ul' && resetStyles.list,
role === 'button' && !isDisabled && resetStyles.ariaButton,
providedStyle,
pointerEvents && pointerEventsStyles[pointerEvents]
];
const { className, style } = resolveStyle(reactNativeStyle) || emptyObject;
const { className, style } = styleResolver(reactNativeStyle);
if (accessible === true) {
domProps.tabIndex = AccessibilityUtil.propsToTabIndex(props);
if (isDisabled) {
domProps['aria-disabled'] = true;
}
if (importantForAccessibility === 'no-hide-descendants') {
domProps['aria-hidden'] = true;
}
if (accessibilityLabel && accessibilityLabel.constructor === String) {
domProps['aria-label'] = accessibilityLabel;
@@ -95,26 +105,21 @@ const createDOMProps = (rnProps, resolveStyle) => {
if (className && className.constructor === String) {
domProps.className = domProps.className ? `${domProps.className} ${className}` : className;
}
if (importantForAccessibility === 'no-hide-descendants') {
domProps['aria-hidden'] = true;
if (component === 'a' && domProps.target === '_blank') {
domProps.rel = `${domProps.rel || ''} noopener noreferrer`;
}
if (role && role.constructor === String) {
if (role && role.constructor === String && role !== 'label') {
domProps.role = role;
if (role === 'button') {
domProps.type = 'button';
} else if (role === 'link' && domProps.target === '_blank') {
domProps.rel = `${domProps.rel || ''} noopener noreferrer`;
}
}
if (style) {
domProps.style = style;
}
if (tabIndex) {
domProps.tabIndex = tabIndex;
}
if (testID && testID.constructor === String) {
domProps['data-testid'] = testID;
}
if (type && type.constructor === String) {
domProps.type = type;
}
return domProps;
};

View File

@@ -17,7 +17,10 @@ const BaseComponentPropTypes = {
accessible: bool,
importantForAccessibility: oneOf(['auto', 'no', 'no-hide-descendants', 'yes']),
style: oneOfType([array, number, object]),
testID: string
testID: string,
// compatibility with React Native
accessibilityComponentType: string,
accessibilityTraits: oneOfType([array, string])
};
export default BaseComponentPropTypes;