Repository: mui/material-ui (TypeScript)
Date: 2020-04-26 19:11:15+00:00
Task type: Feature

Issue:
[Tabs] Implement arrow keyboard
<!-- Have a QUESTION? Please ask in [StackOverflow or gitter](http://tr.im/77pVj. -->


### Problem description

The Tabs component doesn't implement keyboard support as specified by [the WAI-ARIA 1.1 authoring practices][1]. Ideally it would be best if this was implemented in the actual component, since this is standardized behaviour which should be available out of the box with no extra effort from the developer.

[1]: https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel

### Link to minimal working code that reproduces the issue

<!-- You may provide a repository or use our template-ready webpackbin
master: https://www.webpackbin.com/bins/-Kh7G86UTg0ckGC2hL94
next: https://www.webpackbin.com/bins/-Kh8lDulAxDq8j-7yTew

-->

https://github.com/callemall/material-ui/blob/next/docs/src/pages/component-demos/tabs/BasicTabs.js

### Versions

- Material-UI: 352ca6099
- React: 15.5.4
- Browser: Firefox 53.0.3, Chrome 58.0.3029.110


<!-- If you are having an issue with click events, please re-read the [README](http://tr.im/410Fg) (you did read the README, right? :-) ).

If you think you have found a _new_ issue that hasn't already been reported or fixed in HEAD, please complete the template above.

For feature requests, please delete the template above and use this one instead:

### Description
### Images & references

-->



Issue discussion:
Yes, indeed. I'm closing this issue in favor of #4795. Good point.
This comment is relevant too https://github.com/mui-org/material-ui/pull/5604#issuecomment-262122172.
Is anybody currently working on this issue? The lack of keyboard navigation between tabs via the left and right arrow keys is a deal breaker for what I need. If nobody else is working on it, I would be interested in potentially making a PR.
@nikzb Nobody has been working on it over the last 3 years. Notice that Bootstrap doesn't support it either. You are free to go :). 
@nikzb 	I started to look at it a couple of days ago, but got distracted by other things. If you're happy to work on it, I can finish up improving the ARIA story, and together it should be in much better shape for accessibility.

Regarding the keyboard controls: I believe that when a tab is selected by arrow key, the tab key should then select the content, to allow a screen reader to read it.

The W3 spec also recommends that the arrow keys should cause the tab content to change, rather than requiring selection with the space / enter key.

Home and End keys should select the first and last tab respectively. We don't need to support delete.

I have been following this example: https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
> The W3 spec also recommends that the arrow keys should cause the tab content to change, rather than requiring selection with the space / enter key.

@mbrookes Letting the user activate manually seems like the safer default, rather than having the automatic activation, based on what is stated here: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus

Would it make sense to have manual be the default and possibly provide a way to opt-in to automatic activation?
> based on what is stated here: w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus

That seems consistent with the advice in the other sections. It's saying that if a network request for tabpanel content prevents quickly navigating the tabs, the tabs should require manual activation (i.e. don't automatically activate the tab if doing so blocks navigating to other tabs while the content is loading).

Correct me if I am wrong, but this seems like an implementation issue. Done right, there's no reason that loading data should block navigation.

Yes, you could have a "tabPanelDataLoadedSynchronouslyDontTryToRenderItUnlessTheUserAsksTo" option ;), but I believe it would only serve to encourage the problem.

I suspect that element of the spec was written in earlier times before the availability of async / await.
The current behavior when you use the "Tab" key to navigate the tabs is not to activate automatically, but instead let the user activate manually if they want to actually view the content for that tab. Since the automatic vs manual activation issue is a grey area, I think it might make sense to stick with the manual activation, even once the navigation is done with arrow keys rather than the tab key.
I haven't read the discussion in detail. I believe that we should:
- Have a single tab item tabbable. So Tab **shouldn't** navigate between the tabs like we have today.
- Have the ArrowLeft and ArrowRight move the focus and trigger the change event. It's an automatic activation. However, we can add a `reason` second argument like we do with the snackbar. So developers can choose to ignore the change events coming from the arrow interaction.

It's what JQuery UI or Reach UI or WCAG are doing.
Just posting it here for reference : https://reacttraining.com/blog/reach-tabs/
We are exploring a broader solution in #15597.

Solution patch:
diff --git a/.eslintrc.js b/.eslintrc.js
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -116,6 +116,10 @@ module.exports = {
         // does not work with wildcard imports. Mistakes will throw at runtime anyway
         'import/named': 'off',
 
+        // upgraded level from recommended
+        'mocha/no-exclusive-tests': 'error',
+        'mocha/no-skipped-tests': 'error',
+
         // no rationale provided in /recommended
         'mocha/no-mocha-arrows': 'off',
         // definitely a useful rule but too many false positives
diff --git a/packages/material-ui/src/Tab/Tab.js b/packages/material-ui/src/Tab/Tab.js
--- a/packages/material-ui/src/Tab/Tab.js
+++ b/packages/material-ui/src/Tab/Tab.js
@@ -141,6 +141,7 @@ const Tab = React.forwardRef(function Tab(props, ref) {
       aria-selected={selected}
       disabled={disabled}
       onClick={handleChange}
+      tabIndex={selected ? 0 : -1}
       {...other}
     >
       <span className={classes.wrapper}>
diff --git a/packages/material-ui/src/Tabs/Tabs.js b/packages/material-ui/src/Tabs/Tabs.js
--- a/packages/material-ui/src/Tabs/Tabs.js
+++ b/packages/material-ui/src/Tabs/Tabs.js
@@ -122,7 +122,7 @@ const Tabs = React.forwardRef(function Tabs(props, ref) {
   });
   const valueToIndex = new Map();
   const tabsRef = React.useRef(null);
-  const childrenWrapperRef = React.useRef(null);
+  const tabListRef = React.useRef(null);
 
   const getTabsMeta = () => {
     const tabsNode = tabsRef.current;
@@ -145,7 +145,7 @@ const Tabs = React.forwardRef(function Tabs(props, ref) {
 
     let tabMeta;
     if (tabsNode && value !== false) {
-      const children = childrenWrapperRef.current.children;
+      const children = tabListRef.current.children;
 
       if (children.length > 0) {
         const tab = children[valueToIndex.get(value)];
@@ -409,6 +409,47 @@ const Tabs = React.forwardRef(function Tabs(props, ref) {
     });
   });
 
+  const handleKeyDown = (event) => {
+    const { target } = event;
+    // Keyboard navigation assumes that [role="tab"] are siblings
+    // though we might warn in the future about nested, interactive elements
+    // as a a11y violation
+    const role = target.getAttribute('role');
+    if (role !== 'tab') {
+      return;
+    }
+
+    let newFocusTarget = null;
+    let previousItemKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
+    let nextItemKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
+    if (orientation === 'horizontal' && theme.direction === 'rtl') {
+      // swap previousItemKey with nextItemKey
+      previousItemKey = 'ArrowRight';
+      nextItemKey = 'ArrowLeft';
+    }
+
+    switch (event.key) {
+      case previousItemKey:
+        newFocusTarget = target.previousElementSibling || tabListRef.current.lastChild;
+        break;
+      case nextItemKey:
+        newFocusTarget = target.nextElementSibling || tabListRef.current.firstChild;
+        break;
+      case 'Home':
+        newFocusTarget = tabListRef.current.firstChild;
+        break;
+      case 'End':
+        newFocusTarget = tabListRef.current.lastChild;
+        break;
+      default:
+        break;
+    }
+    if (newFocusTarget !== null) {
+      newFocusTarget.focus();
+      event.preventDefault();
+    }
+  };
+
   const conditionalElements = getConditionalElements();
 
   return (
@@ -434,12 +475,15 @@ const Tabs = React.forwardRef(function Tabs(props, ref) {
         ref={tabsRef}
         onScroll={handleTabsScroll}
       >
+        {/* The tablist isn't interactive but the tabs are */}
+        {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
         <div
           className={clsx(classes.flexContainer, {
             [classes.flexContainerVertical]: vertical,
             [classes.centered]: centered && !scrollable,
           })}
-          ref={childrenWrapperRef}
+          onKeyDown={handleKeyDown}
+          ref={tabListRef}
           role="tablist"
         >
           {children}


Test patch:
diff --git a/packages/material-ui/src/Tabs/Tabs.test.js b/packages/material-ui/src/Tabs/Tabs.test.js
--- a/packages/material-ui/src/Tabs/Tabs.test.js
+++ b/packages/material-ui/src/Tabs/Tabs.test.js
@@ -10,6 +10,7 @@ import describeConformance from '../test-utils/describeConformance';
 import Tab from '../Tab';
 import Tabs from './Tabs';
 import TabScrollButton from './TabScrollButton';
+import { createMuiTheme, MuiThemeProvider } from '../styles';
 
 function AccessibleTabScrollButton(props) {
   return <TabScrollButton data-direction={props.direction} {...props} />;
@@ -118,6 +119,21 @@ describe('<Tabs />', () => {
     it('should support empty children', () => {
       render(<Tabs value={1} />);
     });
+
+    it('puts the selected child in tab order', () => {
+      const { getAllByRole, setProps } = render(
+        <Tabs value={1}>
+          <Tab />
+          <Tab />
+        </Tabs>,
+      );
+
+      expect(getAllByRole('tab').map((tab) => tab.tabIndex)).to.have.ordered.members([-1, 0]);
+
+      setProps({ value: 0 });
+
+      expect(getAllByRole('tab').map((tab) => tab.tabIndex)).to.have.ordered.members([0, -1]);
+    });
   });
 
   describe('prop: value', () => {
@@ -672,4 +688,176 @@ describe('<Tabs />', () => {
       expect(indicator).to.have.lengthOf(1);
     });
   });
+
+  describe('keyboard navigation when focus is on a tab', () => {
+    [
+      ['horizontal', 'ltr', 'ArrowLeft', 'ArrowRight'],
+      ['horizontal', 'rtl', 'ArrowRight', 'ArrowLeft'],
+      ['vertical', undefined, 'ArrowUp', 'ArrowDown'],
+    ].forEach((entry) => {
+      const [orientation, direction, previousItemKey, nextItemKey] = entry;
+
+      let wrapper;
+      before(() => {
+        const theme = createMuiTheme({ direction });
+        wrapper = ({ children }) => <MuiThemeProvider theme={theme}>{children}</MuiThemeProvider>;
+      });
+
+      describe(`when focus is on a tab element in a ${orientation} ${direction} tablist`, () => {
+        describe(previousItemKey, () => {
+          it('moves focus to the last tab without activating it if focus is on the first tab', () => {
+            const handleChange = spy();
+            const handleKeyDown = spy((event) => event.defaultPrevented);
+            const { getAllByRole } = render(
+              <Tabs
+                onChange={handleChange}
+                onKeyDown={handleKeyDown}
+                orientation={orientation}
+                value={1}
+              >
+                <Tab />
+                <Tab />
+                <Tab />
+              </Tabs>,
+              { wrapper },
+            );
+            const [firstTab, , lastTab] = getAllByRole('tab');
+            firstTab.focus();
+
+            fireEvent.keyDown(firstTab, { key: previousItemKey });
+
+            expect(lastTab).toHaveFocus();
+            expect(handleChange.callCount).to.equal(0);
+            expect(handleKeyDown.firstCall.returnValue).to.equal(true);
+          });
+
+          it('moves focus to the previous tab without activating it', () => {
+            const handleChange = spy();
+            const handleKeyDown = spy((event) => event.defaultPrevented);
+            const { getAllByRole } = render(
+              <Tabs
+                onChange={handleChange}
+                onKeyDown={handleKeyDown}
+                orientation={orientation}
+                value={1}
+              >
+                <Tab />
+                <Tab />
+                <Tab />
+              </Tabs>,
+              { wrapper },
+            );
+            const [firstTab, secondTab] = getAllByRole('tab');
+            secondTab.focus();
+
+            fireEvent.keyDown(secondTab, { key: previousItemKey });
+
+            expect(firstTab).toHaveFocus();
+            expect(handleChange.callCount).to.equal(0);
+            expect(handleKeyDown.firstCall.returnValue).to.equal(true);
+          });
+        });
+
+        describe(nextItemKey, () => {
+          it('moves focus to the first tab without activating it if focus is on the last tab', () => {
+            const handleChange = spy();
+            const handleKeyDown = spy((event) => event.defaultPrevented);
+            const { getAllByRole } = render(
+              <Tabs
+                onChange={handleChange}
+                onKeyDown={handleKeyDown}
+                orientation={orientation}
+                value={1}
+              >
+                <Tab />
+                <Tab />
+                <Tab />
+              </Tabs>,
+              { wrapper },
+            );
+            const [firstTab, , lastTab] = getAllByRole('tab');
+            lastTab.focus();
+
+            fireEvent.keyDown(lastTab, { key: nextItemKey });
+
+            expect(firstTab).toHaveFocus();
+            expect(handleChange.callCount).to.equal(0);
+            expect(handleKeyDown.firstCall.returnValue).to.equal(true);
+          });
+
+          it('moves focus to the next tab without activating it it', () => {
+            const handleChange = spy();
+            const handleKeyDown = spy((event) => event.defaultPrevented);
+            const { getAllByRole } = render(
+              <Tabs
+                onChange={handleChange}
+                onKeyDown={handleKeyDown}
+                orientation={orientation}
+                value={1}
+              >
+                <Tab />
+                <Tab />
+                <Tab />
+              </Tabs>,
+              { wrapper },
+            );
+            const [, secondTab, lastTab] = getAllByRole('tab');
+            secondTab.focus();
+
+            fireEvent.keyDown(secondTab, { key: nextItemKey });
+
+            expect(lastTab).toHaveFocus();
+            expect(handleChange.callCount).to.equal(0);
+            expect(handleKeyDown.firstCall.returnValue).to.equal(true);
+          });
+        });
+      });
+    });
+
+    describe('when focus is on a tab regardless of orientation', () => {
+      describe('Home', () => {
+        it('moves focus to the first tab without activating it', () => {
+          const handleChange = spy();
+          const handleKeyDown = spy((event) => event.defaultPrevented);
+          const { getAllByRole } = render(
+            <Tabs onChange={handleChange} onKeyDown={handleKeyDown} value={1}>
+              <Tab />
+              <Tab />
+              <Tab />
+            </Tabs>,
+          );
+          const [firstTab, , lastTab] = getAllByRole('tab');
+          lastTab.focus();
+
+          fireEvent.keyDown(lastTab, { key: 'Home' });
+
+          expect(firstTab).toHaveFocus();
+          expect(handleChange.callCount).to.equal(0);
+          expect(handleKeyDown.firstCall.returnValue).to.equal(true);
+        });
+      });
+
+      describe('End', () => {
+        it('moves focus to the last tab without activating it', () => {
+          const handleChange = spy();
+          const handleKeyDown = spy((event) => event.defaultPrevented);
+          const { getAllByRole } = render(
+            <Tabs onChange={handleChange} onKeyDown={handleKeyDown} value={1}>
+              <Tab />
+              <Tab />
+              <Tab />
+            </Tabs>,
+          );
+          const [firstTab, , lastTab] = getAllByRole('tab');
+          firstTab.focus();
+
+          fireEvent.keyDown(firstTab, { key: 'End' });
+
+          expect(lastTab).toHaveFocus();
+          expect(handleChange.callCount).to.equal(0);
+          expect(handleKeyDown.firstCall.returnValue).to.equal(true);
+        });
+      });
+    });
+  });
 });