Repository: mui/material-ui (TypeScript)
Date: 2019-01-17 02:42:08+00:00
Task type: Feature

Issue:
[MenuList] Support Home/End keys and wraparound
It would be nice if `MenuList` supported the optional wraparound for the up/down arrow keyboard navigation as described in the [ARIA keyboard navigation guidelines for menus](https://www.w3.org/TR/wai-aria-practices-1.1/#menu). Same for having the Home/End keys move to the beginning/end of the list.

I'd be happy to submit a PR if this is something the maintainers would want.

- [x ] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [x ] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Expected Behavior 🤔
When the keyboard focus is on any item inside a `MenuList`:
- Pressing the Home key should move focus to the first item in the list.
- Pressing the End key should move focus to the last item in the list.

When the focus is on the last item in the list, pressing the down arrow should set focus on the first item.

When the focus is on the first item in the list, pressing the up arrow should set focus on the last item in the list.

## Current Behavior 😯
Home/End keys have no effect.
Pressing the down arrow when on the last item has no effect.
Pressing the up arrow when on the first item has no effect.

## Examples 🌈
https://www.w3.org/TR/wai-aria-practices-1.1/examples/menu-button/menu-button-actions.html

## Context 🔦
I am developing a WCAG AA-compliant application and want to make the keyboard navigation experience as good as possible for people with accessibility issues, as well as for power users.



Issue discussion:
I'm not sure to understand what you mean. How is this not working as we are speaking? Following our issue template would help :). 
@eric-parsons I agree with @oliviertassinari - deleting the issue template is a bit selfish. We put it there to make it easier for you to provide the information we need to help solve your issue.

That said, looking at the ARIA guidelines, I think I get where you're going: 

> Down Arrow:
    [...]
    When focus is in a menu, moves focus to the next item, **optionally wrapping from the last to the first.**

@oliviertassinari I'm not big fan, I think wrapping menus are actually bad for accessibility. It was hashed out at some point for Speed Dial, and wrap was abandoned. I don't see the value for menus either, but happy to be convinced otherwise.
@mbrookes If the wrapping behavior is opt-in and has a small footprint, I see no harm in supporting it.
My apologies. I thought I had accidentally ended up with the bug report template rather than the new feature template. I've updated the original comment with the missing Expected/Current sections. It was an honest mistake, not trying to be "selfish" (I'm offering to contribute after all).
 
In any case, thanks for having a look. The section in the linked guidelines on Home/End reads as follows:
> * Home: If arrow key wrapping is not supported, moves focus to the first item in the current menu or menubar.
 > * End: If arrow key wrapping is not supported, moves focus to the last item in the current menu or menubar.

This suggests to me that at least one of Home/End or wrapping should be implemented, with the option to implement both. 

@mbrookes, can you elaborate on why you think wrapping would be bad for accessibility? The NVDA screen reader for example will read out "x of y" after each option (so the user would know that wraparound occurred), and people with mobility issues might appreciate having to press less keys to navigate. I'm by no means an expert on accessibility though, which is why I'm trying to follow the guidelines.

As far as implementation goes, it would likely be a few extra lines of trivial code.
> As far as implementation goes, it would likely be a few extra lines of trivial code.

Then I have no objection to add the home/end bindings with an opt-in wrapping logic. @mbrookes?
> @oliviertassinari I'm not big fan, I think wrapping menus are actually bad for accessibility. It was hashed out at some point for Speed Dial, and wrap was abandoned. I don't see the value for menus either, but happy to be convinced otherwise.

Relevant discussion in #12725. I agreed with that only because SpeedDials should not have more than 6 actions (as per guidelines).

Since this is recommended behavior in the WAI-ARIA Authoring Practices I would be in favor of adding that behavior with the ability to opt out.
> with the ability to opt out.

@eps1lon I have benchmarked a lot of alternative UI libs as well as how the browsers and OS handle the problem. It's very inconsistent. So, I think that we should follow the W3C recommendations for the *Menu* component, having the behavior opt-in by default and the ability to opt-out.

However, I would make one exception for the *Select* component. The native component doesn't wrap on MacOS nor on Windows. I think that we should opt-out.

> I'd be happy to submit a PR if this is something the maintainers would want.

@eric-parsons We would be honored! Also, I want to address #13708 at some point.
> The NVDA screen reader for example will read out "x of y" after each option (so the user would know that wraparound occurred)

That's good to know. If that's consistent across screen readers, it solves my main concern. It's unfortunate the ARIA guidelines don't tie the recommendations to the resulting UX.

@oliviertassinari 100% for Home / End key support. Wrapping default with opt-out is fine by me.


Thanks everyone for your feedback. I'll get going on a PR then.

Solution patch:
diff --git a/.size-limit.js b/.size-limit.js
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -22,7 +22,7 @@ module.exports = [
     name: 'The size of the @material-ui/core modules',
     webpack: true,
     path: 'packages/material-ui/build/index.js',
-    limit: '95.2 KB',
+    limit: '95.3 KB',
   },
   {
     name: 'The size of the @material-ui/styles modules',
diff --git a/packages/material-ui/src/DialogContentText/DialogContentText.js b/packages/material-ui/src/DialogContentText/DialogContentText.js
--- a/packages/material-ui/src/DialogContentText/DialogContentText.js
+++ b/packages/material-ui/src/DialogContentText/DialogContentText.js
@@ -8,7 +8,7 @@ import Typography from '../Typography';
 export const styles = {
   /* Styles applied to the root element. */
   root: {
-    // Should use variant="body1" in v4.0.0
+    // Should use variant="body1" in v4
     lineHeight: 1.5,
   },
 };
diff --git a/packages/material-ui/src/ListItem/ListItem.js b/packages/material-ui/src/ListItem/ListItem.js
--- a/packages/material-ui/src/ListItem/ListItem.js
+++ b/packages/material-ui/src/ListItem/ListItem.js
@@ -18,9 +18,9 @@ export const styles = theme => ({
     width: '100%',
     boxSizing: 'border-box',
     textAlign: 'left',
-    paddingTop: 11, // To use 10px in v4.0.0
-    paddingBottom: 11, // To use 10px in v4.0.0
-    '&$selected, &$selected:hover': {
+    paddingTop: 11, // To use 10px in v4
+    paddingBottom: 11, // To use 10px in v4
+    '&$selected, &$selected:hover, &$selected:focus': {
       backgroundColor: theme.palette.action.selected,
     },
   },
@@ -28,11 +28,9 @@ export const styles = theme => ({
   container: {
     position: 'relative',
   },
-  // TODO: Sanity check this - why is focusVisibleClassName prop apparently applied to a div?
+  // To remove in v4
   /* Styles applied to the `component`'s `focusVisibleClassName` property if `button={true}`. */
-  focusVisible: {
-    backgroundColor: theme.palette.action.hover,
-  },
+  focusVisible: {},
   /* Legacy styles applied to the root element. Use `root` instead. */
   default: {},
   /* Styles applied to the `component` element if `dense={true}` or `children` includes `Avatar`. */
@@ -71,6 +69,9 @@ export const styles = theme => ({
         backgroundColor: 'transparent',
       },
     },
+    '&:focus': {
+      backgroundColor: theme.palette.action.hover,
+    },
   },
   /* Styles applied to the `component` element if `children` includes `ListItemSecondaryAction`. */
   secondaryAction: {
diff --git a/packages/material-ui/src/MenuList/MenuList.js b/packages/material-ui/src/MenuList/MenuList.js
--- a/packages/material-ui/src/MenuList/MenuList.js
+++ b/packages/material-ui/src/MenuList/MenuList.js
@@ -69,6 +69,12 @@ class MenuList extends React.Component {
       } else if (!this.props.disableListWrap) {
         list.lastChild.focus();
       }
+    } else if (key === 'home') {
+      event.preventDefault();
+      list.firstChild.focus();
+    } else if (key === 'end') {
+      event.preventDefault();
+      list.lastChild.focus();
     }
 
     if (this.props.onKeyDown) {
diff --git a/packages/material-ui/src/Select/Select.js b/packages/material-ui/src/Select/Select.js
--- a/packages/material-ui/src/Select/Select.js
+++ b/packages/material-ui/src/Select/Select.js
@@ -9,7 +9,7 @@ import withFormControlContext from '../FormControl/withFormControlContext';
 import withStyles from '../styles/withStyles';
 import mergeClasses from '../styles/mergeClasses';
 import ArrowDropDownIcon from '../internal/svg-icons/ArrowDropDown';
-// To replace with InputBase in v4.0.0
+// To replace with InputBase in v4
 import Input from '../Input';
 import { styles as nativeSelectStyles } from '../NativeSelect/NativeSelect';
 import NativeSelectInput from '../NativeSelect/NativeSelectInput';


Test patch:
diff --git a/packages/material-ui/test/integration/Menu.test.js b/packages/material-ui/test/integration/Menu.test.js
--- a/packages/material-ui/test/integration/Menu.test.js
+++ b/packages/material-ui/test/integration/Menu.test.js
@@ -62,6 +62,16 @@ describe('<Menu> integration', () => {
       assert.strictEqual(document.activeElement, portalLayer.querySelectorAll('li')[2]);
     });
 
+    it('should switch focus from the 3rd item to the 1st item when home key is pressed', () => {
+      TestUtils.Simulate.keyDown(portalLayer.querySelector('ul'), { which: keycode('home') });
+      assert.strictEqual(document.activeElement, portalLayer.querySelectorAll('li')[0]);
+    });
+
+    it('should switch focus from the 1st item to the 3rd item when end key is pressed', () => {
+      TestUtils.Simulate.keyDown(portalLayer.querySelector('ul'), { which: keycode('end') });
+      assert.strictEqual(document.activeElement, portalLayer.querySelectorAll('li')[2]);
+    });
+
     it('should keep focus on the last item when a key with no associated action is pressed', () => {
       TestUtils.Simulate.keyDown(portalLayer.querySelector('ul'), { which: keycode('right') });
       assert.strictEqual(document.activeElement, portalLayer.querySelectorAll('li')[2]);