Skip to content
This repository has been archived by the owner on Oct 19, 2021. It is now read-only.

Commit

Permalink
feat(OverflowMenu): add arrow key navigation (#1822)
Browse files Browse the repository at this point in the history
* refactor(OverflowMenu): use keycode matching util functions

* refactor(OverflowMenuItem): refactor to class component for ref access

* feat(OverflowMenu): add arrow key navigation

* fix(OverflowMenuItem): avoid spreading invalid attributes

* feat(OverflowMenu): focus on first menu item on menu open

* docs(OverflowMenu): avoid redundant prop value

* fix(OverflowMenuItem): disallow tab key navigation

* fix(OverflowMenu): close menu on blur

* docs(OverflowMenu): add additional menu to test keyboard navigation

* fix(OverflowMenu): prevent blur handler is menu is floating
  • Loading branch information
emyarod authored and tw15egan committed Feb 11, 2019
1 parent 960303e commit da13fda
Show file tree
Hide file tree
Showing 3 changed files with 280 additions and 180 deletions.
79 changes: 46 additions & 33 deletions src/components/OverflowMenu/OverflowMenu-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,38 +55,51 @@ storiesOf('OverflowMenu', module)
() => {
const overflowMenuItemProps = props.menuItem();
return (
<OverflowMenu {...props.menu()}>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 1"
primaryFocus={true}
/>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 2 is an example of a really long string and how we recommend handling this"
requireTitle
/>
<OverflowMenuItem {...overflowMenuItemProps} itemText="Option 3" />
<OverflowMenuItem {...overflowMenuItemProps} itemText="Option 4" />
<OverflowMenuItem
{...overflowMenuItemProps}
itemText={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}>
Add <Icon icon={iconAdd} style={{ height: '12px' }} />
</div>
}
/>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Danger option"
hasDivider
isDelete
/>
</OverflowMenu>
<>
<OverflowMenu {...props.menu()}>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 1"
primaryFocus
/>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 2 is an example of a really long string and how we recommend handling this"
requireTitle
/>
<OverflowMenuItem {...overflowMenuItemProps} itemText="Option 3" />
<OverflowMenuItem {...overflowMenuItemProps} itemText="Option 4" />
<OverflowMenuItem
{...overflowMenuItemProps}
itemText={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
}}>
Add <Icon icon={iconAdd} style={{ height: '12px' }} />
</div>
}
/>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Danger option"
hasDivider
isDelete
/>
</OverflowMenu>
<OverflowMenu {...props.menu()}>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 1"
primaryFocus
/>
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 2 is an example of a really long string and how we recommend handling this"
/>
</OverflowMenu>
</>
);
},
{
Expand All @@ -110,7 +123,7 @@ storiesOf('OverflowMenu', module)
<OverflowMenuItem
{...overflowMenuItemProps}
itemText="Option 1"
primaryFocus={true}
primaryFocus
/>
<OverflowMenuItem
{...overflowMenuItemProps}
Expand Down
93 changes: 80 additions & 13 deletions src/components/OverflowMenu/OverflowMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Icon from '../Icon';
// TODO: import { OverflowMenuVertical16 } from '@carbon/icons-react';
import OverflowMenuVertical16 from '@carbon/icons-react/lib/overflow-menu--vertical/16';
import { componentsX } from '../../internal/FeatureFlags';
import { keys, matches as keyCodeMatches } from '../../tools/key';

const { prefix } = settings;

Expand Down Expand Up @@ -330,15 +331,35 @@ export default class OverflowMenu extends Component {
});
}

getPrimaryFocusableElement = () => {
if (this.menuEl) {
const primaryFocusPropEl = this.menuEl.querySelector(
'[data-floating-menu-primary-focus]'
);
if (primaryFocusPropEl) {
return primaryFocusPropEl;
}
}
const firstItem = this.overflowMenuItem0;
if (
firstItem &&
firstItem.overflowMenuItem &&
firstItem.overflowMenuItem.current
) {
return firstItem.overflowMenuItem.current;
}
};

componentDidUpdate() {
const { onClose, onOpen, floatingMenu } = this.props;

if (this.state.open) {
if (!floatingMenu) {
(
this.menuEl.querySelector('[data-overflow-menu-primary-focus]') ||
this.menuEl
).focus();
const primaryFocusableElement = this.getPrimaryFocusableElement();
if (primaryFocusableElement) {
primaryFocusableElement.focus();
}

onOpen();
}
} else {
Expand Down Expand Up @@ -373,23 +394,22 @@ export default class OverflowMenu extends Component {
};

handleKeyDown = evt => {
if (evt.which === 40) {
if (keyCodeMatches(evt, [keys.DOWN])) {
this.setState({ open: !this.state.open });
this.props.onClick(evt);
}
};

handleKeyPress = evt => {
// only respond to key events when the menu is closed, so that menu items still respond to key events
const key = evt.key || evt.which;
if (!this.state.open) {
if (key === 'Enter' || key === 13 || key === ' ' || key === 32) {
if (keyCodeMatches(evt, [keys.ENTER, keys.SPACE])) {
this.setState({ open: true });
}
}

// Close the overflow menu on escape
if (key === 'Escape' || key === 'Esc' || key === 27) {
if (keyCodeMatches(evt, [keys.ESC])) {
this.closeMenu();
// Stop the esc keypress from bubbling out and closing something it shouldn't
evt.stopPropagation();
Expand All @@ -403,6 +423,27 @@ export default class OverflowMenu extends Component {
}
};

/**
* collapse menu when focus is lost
* Due to Storybook hijacking focus, we must wrap our `activeElement` check
* in a closure. As a result of this hack, we must also call `event.persist()`
* to ensure that we can access the event properties asynchronously
*
* https://reactjs.org/docs/events.html#event-pooling
*/
handleBlur = evt => {
if (this.props.floatingMenu) {
return;
}
evt.persist();
// event loop hack
setTimeout(() => {
if (!this.menuEl.contains(evt.target.ownerDocument.activeElement)) {
this.setState({ open: false });
}
}, 0);
};

closeMenu = () => {
let wasOpen = this.state.open;
this.setState({ open: false }, () => {
Expand All @@ -423,6 +464,25 @@ export default class OverflowMenu extends Component {
}
};

handleOverflowMenuItemFocus = index => {
const i = (() => {
switch (index) {
case -1:
return React.Children.count(this.props.children) - 1;
case React.Children.count(this.props.children):
return 0;
default:
return index;
}
})();
const { overflowMenuItem } =
this[`overflowMenuItem${i}`] ||
React.Children.toArray(this.props.children)[i];
if (overflowMenuItem && overflowMenuItem.current) {
overflowMenuItem.current.focus();
}
};

/**
* Handles the floating menu being unmounted.
* @param {Element} menuBody The DOM element of the menu body.
Expand Down Expand Up @@ -531,11 +591,17 @@ export default class OverflowMenu extends Component {
iconClass
);

const childrenWithProps = React.Children.toArray(children).map(child =>
React.cloneElement(child, {
closeMenu: this.closeMenu,
floatingMenu: floatingMenu || undefined,
})
const childrenWithProps = React.Children.toArray(children).map(
(child, index) =>
React.cloneElement(child, {
closeMenu: this.closeMenu,
floatingMenu: floatingMenu || undefined,
handleOverflowMenuItemFocus: this.handleOverflowMenuItemFocus,
ref: e => {
this[`overflowMenuItem${index}`] = e;
},
index,
})
);

const menuBody = (
Expand Down Expand Up @@ -601,6 +667,7 @@ export default class OverflowMenu extends Component {
aria-expanded={this.state.open}
className={overflowMenuClasses}
onKeyDown={this.handleKeyPress}
onBlur={this.handleBlur}
onClick={this.handleClick}
aria-label={ariaLabel}
id={id}
Expand Down
Loading

0 comments on commit da13fda

Please sign in to comment.