Skip to content

Commit

Permalink
fix(ui-shell): improve keyboard accessibility (carbon-design-system#1813
Browse files Browse the repository at this point in the history
)

* fix(ui-shell): remove aria menu keyhandlers

* fix(ui-shell): remove unused variables

* fix(ui-shell): return focus to menu button on escape

* fix(ui-shell): remove unused variables

* fix(ui-shell): run prettier and update snap shots
  • Loading branch information
dakahn authored Feb 1, 2019
1 parent caa6ff9 commit 38beacd
Show file tree
Hide file tree
Showing 5 changed files with 39 additions and 188 deletions.
127 changes: 27 additions & 100 deletions src/components/UIShell/HeaderMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { settings } from 'carbon-components';
import cx from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import { keys, match, matches } from '../../tools/key';
import { keys, matches } from '../../tools/key';
import { AriaLabelPropType } from '../../prop-types/AriaPropTypes';

const { prefix } = settings;
Expand Down Expand Up @@ -52,112 +52,43 @@ class HeaderMenu extends React.Component {
}

/**
* Handle expansion state
* Toggle the expanded state of the menu on click.
*/
handleOnMouseOver = () => {
this.setState({ expanded: true });
handleOnClick = () => {
this.setState(prevState => ({
expanded: !prevState.expanded,
}));
};

/**
* Handle collapse state. The `mouseleave` event is used here instead of
* `mouseout` because `mouseout` will fire if we move our mouse over
* menuitems.
*/
handleOnMouseLeave = () => {
this.setState({ expanded: false });
};

/**
* Keyboard event handler for the entire menu. Handles the behavior as
* described in:
* https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-1/menubar-1.html#kbd2_label
* Keyboard event handler for the entire menu.
*/
handleOnKeyDown = event => {
// If we recieve a RIGHT or LEFT key event we should close our menu and
// allow the event to propagate to the corresponding parent menubar or menu
if (matches(event, [keys.RIGHT, keys.LEFT])) {
this.setState({ expanded: false, selectedIndex: null });
return;
}

// If we receive an ESC key event we want to close the menu and restore
// focus to the menu button
if (match(event, keys.ESC)) {
// Handle enter or space key for toggling the expanded state of the menu.
if (matches(event, [keys.ENTER, keys.SPACE])) {
event.stopPropagation();
this.setState({ expanded: false, selectedIndex: null }, () => {
this.menuButtonRef.focus();
});
return;
}

// If we recieve a HOME or END keyboard event we want to prevent the default
// behavior (which is to scroll to the beginning or end of the document) and
// also stop the event from propagating.
//
// We also want to update the selectedIndex value accordingly and then focus
// the corresponding menuitem.
//
// Our final check on selectedIndex is to make sure that we don't cancel the
// HOME or END events for the menubar. We should propagate these events if
// our menu is not open.
if (
matches(event, [keys.HOME, keys.END]) &&
this.state.selectedIndex !== null
) {
event.preventDefault();
event.stopPropagation();
const selectedIndex = match(event, keys.HOME) ? 0 : this.items.length - 1;
this.setState({ selectedIndex }, () => {
this.items[this.state.selectedIndex].focus();
});

this.setState(prevState => ({
expanded: !prevState.expanded,
}));

return;
}

if (matches(event, [keys.DOWN, keys.UP])) {
// Handle ESC keydown for closing the expanded menu.
if (matches(event, [keys.ESC]) && this.state.expanded) {
event.stopPropagation();
const { which } = event;
this.setState(
state => {
// We use the modulo (%) operator here to implement a circular
// buffer so that when we hit the end of the list it wraps to the
// beginning, and when it hits the beginning of a list it wraps to the
// end.
const selectedIndex = match(which, keys.DOWN)
? (state.selectedIndex + 1) % this.items.length
: (state.selectedIndex + this.items.length - 1) % this.items.length;
return {
selectedIndex,
};
},
() => {
this.items[this.state.selectedIndex].focus();
}
);
}
};
event.preventDefault();

/**
* Handles keyboard events on the menu button. Only needs to support ArrowUp
* and ArrowDown and should set the focus position accordingly on the child
* item.
*
* We stop this event from propagating because a parent menubar or menu does
* not need this event.
*/
handleMenuButtonKeyDown = event => {
if (matches(event, [keys.DOWN, keys.UP])) {
event.stopPropagation();
const selectedIndex =
event.which === keys.DOWN ? 0 : this.items.length - 1;
this.setState(
{
expanded: true,
selectedIndex,
},
() => {
this.items[this.state.selectedIndex].focus();
}
);
this.setState(() => ({
expanded: false,
selectedIndex: null,
}));

// Return focus to menu button when the user hits ESC.
this.menuButtonRef.focus();
return;
}
};

Expand Down Expand Up @@ -204,7 +135,6 @@ class HeaderMenu extends React.Component {
'aria-labelledby': ariaLabelledBy,
className: customClassName,
children,
tabIndex,
} = this.props;
const accessibilityLabel = {
'aria-label': ariaLabel,
Expand All @@ -222,8 +152,7 @@ class HeaderMenu extends React.Component {
<li // eslint-disable-line jsx-a11y/mouse-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions
className={className}
onKeyDown={this.handleOnKeyDown}
onMouseOver={this.handleOnMouseOver}
onMouseLeave={this.handleOnMouseLeave}
onClick={this.handleOnClick}
onBlur={this.handleOnBlur}>
<a // eslint-disable-line jsx-a11y/role-supports-aria-props,jsx-a11y/anchor-is-valid
aria-haspopup="menu" // eslint-disable-line jsx-a11y/aria-proptypes
Expand All @@ -235,8 +164,7 @@ class HeaderMenu extends React.Component {
href="javascript:void(0)"
ref={this.handleMenuButtonRef}
role="menuitem"
tabIndex={tabIndex}
onKeyDown={this.handleMenuButtonKeyDown}>
tabIndex={0}>
{ariaLabel}
<ChevronDownGlyph className={`${prefix}--header__menu-arrow`} />
</a>
Expand Down Expand Up @@ -264,7 +192,6 @@ class HeaderMenu extends React.Component {
return React.cloneElement(item, {
ref: this.handleItemRef(index),
role: 'none',
tabIndex: index !== 0 ? -1 : 0,
});
};
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/UIShell/HeaderMenuItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const HeaderMenuItem = props => {
{...rest}
className={`${prefix}--header__menu-item`}
ref={innerRef}
role="menuitem">
role="menuitem"
tabIndex={0}>
<span className={`${prefix}--text-truncate--end`}>{children}</span>
</Link>
</li>
Expand Down
75 changes: 2 additions & 73 deletions src/components/UIShell/HeaderNavigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import cx from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import { AriaLabelPropType } from '../../prop-types/AriaPropTypes';
import { keys, match, matches, getCharacterFor } from '../../tools/key';

const { prefix } = settings;

Expand Down Expand Up @@ -41,67 +40,6 @@ export default class HeaderNavigation extends React.Component {
};
}

handleOnKeyDown = event => {
if (matches(event, [keys.LEFT, keys.RIGHT, keys.HOME, keys.END])) {
event.stopPropagation();
const { which } = event;
this.setState(
state => {
const { selectedIndex } = state;
const { length } = this.items;
let nextSelectedIndex = null;

// We use the modulo (%) operator here to implement a circular
// buffer so that when we hit the end of the list it wraps to the
// beginning, and when it hits the beginning of a list it wraps to the
// end.
if (match(which, keys.RIGHT)) {
nextSelectedIndex = (selectedIndex + 1) % length;
}
if (match(which, keys.LEFT)) {
nextSelectedIndex = (selectedIndex + length - 1) % length;
}
if (match(which, keys.HOME)) {
nextSelectedIndex = 0;
}
if (match(which, keys.END)) {
nextSelectedIndex = length - 1;
}

return {
selectedIndex: nextSelectedIndex,
};
},
() => {
this.items[this.state.selectedIndex].focus();
}
);
return;
}

// Support transitioning to next item through typing the first letter of
// that menu item
const character = getCharacterFor(event);

this.setState(state => {
let nextSelectedIndex = null;
for (let i = state.selectedIndex; i < this.items.length; i++) {
const item = this.items[i];
if (item.textContent[0].toLowerCase() === character.toLowerCase()) {
item.focus();
nextSelectedIndex = i;
break;
}
}

if (nextSelectedIndex) {
return {
selectedIndex: nextSelectedIndex,
};
}
});
};

/**
* Handles individual menuitem refs. We assign them to a class instance
* property so that we can properly manage focus of our children.
Expand All @@ -126,17 +64,12 @@ export default class HeaderNavigation extends React.Component {
'aria-labelledby': ariaLabelledBy,
};

// Since the menubar presents a site navigation system, it is wrapped in a
// navigation region implemented with a nav element that has an aria-label
// that matches the label on the menubar.
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-1/menubar-1.html#
return (
<nav {...rest} {...accessibilityLabel} className={className}>
<ul
{...accessibilityLabel}
className={`${prefix}--header__menu-bar`}
role="menubar"
onKeyDown={this.handleOnKeyDown}>
role="menubar">
{React.Children.map(children, this._renderNavItem)}
</ul>
</nav>
Expand All @@ -145,15 +78,11 @@ export default class HeaderNavigation extends React.Component {

/**
* Render an individual menuitem, adding a `ref` for each child inside of
* `this.items` to properly manage focus. In addition to this focus management,
* all items receive a `tabIndex: -1`, unless it is the selected item,
* so the user won't hit a large number of items in their tab sequence when they
* might not want to go through all the items.
* `this.items` to properly manage focus.
*/
_renderNavItem = (child, index) => {
return React.cloneElement(child, {
ref: this.handleItemRef(index),
tabIndex: this.state.selectedIndex !== index ? -1 : 0,
});
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,16 @@ exports[`HeaderMenu should render 1`] = `
<li
className="bx--header__submenu custom-class"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
onMouseLeave={[Function]}
onMouseOver={[Function]}
>
<a
aria-expanded={false}
aria-haspopup="menu"
className="bx--header__menu-item bx--header__menu-title"
href="javascript:void(0)"
onKeyDown={[Function]}
role="menuitem"
tabIndex={-1}
tabIndex={0}
>
Accessibility label
<ChevronDownGlyph
Expand Down Expand Up @@ -67,13 +65,11 @@ exports[`HeaderMenu should render 1`] = `
href="/a"
key=".0"
role="none"
tabIndex={0}
>
<HeaderMenuItem
href="/a"
innerRef={[Function]}
role="none"
tabIndex={0}
>
<li
role="none"
Expand Down Expand Up @@ -105,13 +101,11 @@ exports[`HeaderMenu should render 1`] = `
href="/b"
key=".1"
role="none"
tabIndex={-1}
>
<HeaderMenuItem
href="/b"
innerRef={[Function]}
role="none"
tabIndex={-1}
>
<li
role="none"
Expand All @@ -121,13 +115,13 @@ exports[`HeaderMenu should render 1`] = `
element="a"
href="/b"
role="menuitem"
tabIndex={-1}
tabIndex={0}
>
<a
className="bx--header__menu-item"
href="/b"
role="menuitem"
tabIndex={-1}
tabIndex={0}
>
<span
className="bx--text-truncate--end"
Expand All @@ -143,13 +137,11 @@ exports[`HeaderMenu should render 1`] = `
href="/c"
key=".2"
role="none"
tabIndex={-1}
>
<HeaderMenuItem
href="/c"
innerRef={[Function]}
role="none"
tabIndex={-1}
>
<li
role="none"
Expand All @@ -159,13 +151,13 @@ exports[`HeaderMenu should render 1`] = `
element="a"
href="/c"
role="menuitem"
tabIndex={-1}
tabIndex={0}
>
<a
className="bx--header__menu-item"
href="/c"
role="menuitem"
tabIndex={-1}
tabIndex={0}
>
<span
className="bx--text-truncate--end"
Expand Down
Loading

0 comments on commit 38beacd

Please sign in to comment.