Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Tabs] Implement keyboard navigation #20781

Merged
merged 5 commits into from
Apr 27, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed from #20226

'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
Expand Down
1 change: 1 addition & 0 deletions packages/material-ui/src/Tab/Tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
Expand Down
44 changes: 41 additions & 3 deletions packages/material-ui/src/Tabs/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)];
Expand Down Expand Up @@ -409,6 +409,41 @@ 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;
const previousItemKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
eps1lon marked this conversation as resolved.
Show resolved Hide resolved
const nextItemKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
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 (
Expand All @@ -434,12 +469,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}
Expand Down
176 changes: 176 additions & 0 deletions packages/material-ui/src/Tabs/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,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', () => {
Expand Down Expand Up @@ -672,4 +687,165 @@ describe('<Tabs />', () => {
expect(indicator).to.have.lengthOf(1);
});
});

describe('keyboard navigation when focus is on a tab', () => {
[
['horizontal', 'ArrowLeft', 'ArrowRight'],
['vertical', 'ArrowUp', 'ArrowDown'],
].forEach((entry) => {
const [orientation, previousItemKey, nextItemKey] = entry;

describe(`when focus is on a tab element in a ${orientation} 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>,
);
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>,
);
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>,
);
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>,
);
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);
});
});
});
});
});