[fix] 'menuitem' role supports Enter/Space keyboard interaction

The 'menuitem' ARIA role should support Enter/Space keyboard interaction
as if it were a button. This is required because the ARIA spec makes it
so that the ARIA properties of 'menuitem' children are ignored, i.e.,
you can't just wrap a button in a 'menuitem' and expect Assistive
Technologies to surface the button to users.

Fix #1068
Close #1069
This commit is contained in:
Charlie Croom
2018-08-10 13:54:31 -07:00
committed by Nicolas Gallagher
parent 1f06229289
commit 505e3faee8
6 changed files with 64 additions and 23 deletions

View File

@@ -36,24 +36,38 @@ describe('modules/createElement', () => {
expect(component.find('div').length).toBe(1);
});
[{ disabled: true }, { disabled: false }].forEach(({ disabled }) => {
describe(`value is "button" 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(
createElement('span', { accessibilityRole: 'button', disabled, onClick })
);
component.find('span').simulate('keyPress', {
isDefaultPrevented() {},
nativeEvent: {},
preventDefault() {},
which
});
expect(onClick).toHaveBeenCalledTimes(disabled ? 0 : 1);
const testRole = ({ accessibilityRole, disabled }) => {
[{ key: 'Enter', which: 13 }, { key: 'Space', which: 32 }].forEach(({ key, which }) => {
test(`"onClick" is ${disabled ? 'not ' : ''}called when "${key}" key is pressed`, () => {
const onClick = jest.fn();
const component = shallow(
createElement('span', { accessibilityRole, disabled, onClick })
);
component.find('span').simulate('keyPress', {
isDefaultPrevented() {},
nativeEvent: {},
preventDefault() {},
which
});
expect(onClick).toHaveBeenCalledTimes(disabled ? 0 : 1);
});
});
};
describe('value is "button" and disabled is "true"', () => {
testRole({ accessibilityRole: 'button', disabled: true });
});
describe('value is "button" and disabled is "false"', () => {
testRole({ accessibilityRole: 'button', disabled: false });
});
describe('value is "menuitem" and disabled is "true"', () => {
testRole({ accessibilityRole: 'menuitem', disabled: true });
});
describe('value is "menuitem" and disabled is "false"', () => {
testRole({ accessibilityRole: 'menuitem', disabled: false });
});
});
});

View File

@@ -48,7 +48,7 @@ const eventHandlerNames = {
const adjustProps = domProps => {
const { onClick, onResponderRelease, role } = domProps;
const isButtonRole = role === 'button';
const isButtonLikeRole = AccessibilityUtil.buttonLikeRoles[role];
const isDisabled = AccessibilityUtil.isDisabled(domProps);
const isLinkRole = role === 'link';
@@ -56,7 +56,7 @@ const adjustProps = domProps => {
const prop = domProps[propName];
const isEventHandler = typeof prop === 'function' && eventHandlerNames[propName];
if (isEventHandler) {
if (isButtonRole && isDisabled) {
if (isButtonLikeRole && isDisabled) {
domProps[propName] = undefined;
} else {
// TODO: move this out of the render path
@@ -80,8 +80,8 @@ const adjustProps = domProps => {
};
}
// Button role should trigger 'onClick' if SPACE or ENTER keys are pressed.
if (isButtonRole && !isDisabled) {
// Button-like roles should trigger 'onClick' if SPACE or ENTER keys are pressed.
if (isButtonLikeRole && !isDisabled) {
domProps.onKeyPress = function(e) {
if (!e.isDefaultPrevented() && (e.which === 13 || e.which === 32)) {
e.preventDefault();

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) 2017-present, Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
const buttonLikeRoles: { [string]: boolean } = {
// ARIA button behaves like native 'button' element
button: true,
// ARIA menuitem responds to Enter/Space like a button. Spec requires AT to
// ignore ARIA roles of any children.
// https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
menuitem: true
};
export default buttonLikeRoles;

View File

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

View File

@@ -65,9 +65,7 @@ describe('modules/createDOMProps', () => {
});
});
describe('"accessibilityRole" of "button"', () => {
const accessibilityRole = 'button';
const testFocusableRole = accessibilityRole => {
test('default case', () => {
expect(createProps({ accessibilityRole })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
@@ -118,6 +116,14 @@ describe('modules/createDOMProps', () => {
})
).not.toEqual(expect.objectContaining({ 'data-focusable': true, tabIndex: '0' }));
});
};
describe('"accessibilityRole" of "button"', () => {
testFocusableRole('button');
});
describe('"accessibilityRole" of "menuitem"', () => {
testFocusableRole('menuitem');
});
describe('with unfocusable accessibilityRole', () => {

View File

@@ -131,7 +131,7 @@ const createDOMProps = (component, props, styleResolver) => {
} else {
domProps['data-focusable'] = true;
}
} else if (role === 'button' || role === 'textbox') {
} else if (AccessibilityUtil.buttonLikeRoles[role] || role === 'textbox') {
if (accessible !== false && focusable) {
domProps['data-focusable'] = true;
domProps.tabIndex = '0';