[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
@@ -36,13 +36,12 @@ describe('modules/createElement', () => {
expect(component.find('div').length).toBe(1); expect(component.find('div').length).toBe(1);
}); });
[{ disabled: true }, { disabled: false }].forEach(({ disabled }) => { const testRole = ({ accessibilityRole, disabled }) => {
describe(`value is "button" and disabled is "${disabled}"`, () => { [{ key: 'Enter', which: 13 }, { key: 'Space', which: 32 }].forEach(({ key, which }) => {
[{ name: 'Enter', which: 13 }, { name: 'Space', which: 32 }].forEach(({ name, which }) => { test(`"onClick" is ${disabled ? 'not ' : ''}called when "${key}" key is pressed`, () => {
test(`"onClick" is ${disabled ? 'not ' : ''}called when "${name}" is pressed`, () => {
const onClick = jest.fn(); const onClick = jest.fn();
const component = shallow( const component = shallow(
createElement('span', { accessibilityRole: 'button', disabled, onClick }) createElement('span', { accessibilityRole, disabled, onClick })
); );
component.find('span').simulate('keyPress', { component.find('span').simulate('keyPress', {
isDefaultPrevented() {}, isDefaultPrevented() {},
@@ -53,7 +52,22 @@ describe('modules/createElement', () => {
expect(onClick).toHaveBeenCalledTimes(disabled ? 0 : 1); 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 });
}); });
}); });
}); });
@@ -48,7 +48,7 @@ const eventHandlerNames = {
const adjustProps = domProps => { const adjustProps = domProps => {
const { onClick, onResponderRelease, role } = domProps; const { onClick, onResponderRelease, role } = domProps;
const isButtonRole = role === 'button'; const isButtonLikeRole = AccessibilityUtil.buttonLikeRoles[role];
const isDisabled = AccessibilityUtil.isDisabled(domProps); const isDisabled = AccessibilityUtil.isDisabled(domProps);
const isLinkRole = role === 'link'; const isLinkRole = role === 'link';
@@ -56,7 +56,7 @@ const adjustProps = domProps => {
const prop = domProps[propName]; const prop = domProps[propName];
const isEventHandler = typeof prop === 'function' && eventHandlerNames[propName]; const isEventHandler = typeof prop === 'function' && eventHandlerNames[propName];
if (isEventHandler) { if (isEventHandler) {
if (isButtonRole && isDisabled) { if (isButtonLikeRole && isDisabled) {
domProps[propName] = undefined; domProps[propName] = undefined;
} else { } else {
// TODO: move this out of the render path // 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. // Button-like roles should trigger 'onClick' if SPACE or ENTER keys are pressed.
if (isButtonRole && !isDisabled) { if (isButtonLikeRole && !isDisabled) {
domProps.onKeyPress = function(e) { domProps.onKeyPress = function(e) {
if (!e.isDefaultPrevented() && (e.which === 13 || e.which === 32)) { if (!e.isDefaultPrevented() && (e.which === 13 || e.which === 32)) {
e.preventDefault(); e.preventDefault();
@@ -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;
@@ -7,11 +7,13 @@
* @flow * @flow
*/ */
import buttonLikeRoles from './buttonLikeRoles';
import isDisabled from './isDisabled'; import isDisabled from './isDisabled';
import propsToAccessibilityComponent from './propsToAccessibilityComponent'; import propsToAccessibilityComponent from './propsToAccessibilityComponent';
import propsToAriaRole from './propsToAriaRole'; import propsToAriaRole from './propsToAriaRole';
const AccessibilityUtil = { const AccessibilityUtil = {
buttonLikeRoles,
isDisabled, isDisabled,
propsToAccessibilityComponent, propsToAccessibilityComponent,
propsToAriaRole propsToAriaRole
@@ -65,9 +65,7 @@ describe('modules/createDOMProps', () => {
}); });
}); });
describe('"accessibilityRole" of "button"', () => { const testFocusableRole = accessibilityRole => {
const accessibilityRole = 'button';
test('default case', () => { test('default case', () => {
expect(createProps({ accessibilityRole })).toEqual( expect(createProps({ accessibilityRole })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' }) expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
@@ -118,6 +116,14 @@ describe('modules/createDOMProps', () => {
}) })
).not.toEqual(expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })); ).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', () => { describe('with unfocusable accessibilityRole', () => {
@@ -131,7 +131,7 @@ const createDOMProps = (component, props, styleResolver) => {
} else { } else {
domProps['data-focusable'] = true; domProps['data-focusable'] = true;
} }
} else if (role === 'button' || role === 'textbox') { } else if (AccessibilityUtil.buttonLikeRoles[role] || role === 'textbox') {
if (accessible !== false && focusable) { if (accessible !== false && focusable) {
domProps['data-focusable'] = true; domProps['data-focusable'] = true;
domProps.tabIndex = '0'; domProps.tabIndex = '0';