Skip to content

Commit

Permalink
[changed] tab keyboard navigation to be more inline with ARIA spec
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense authored and taion committed Sep 12, 2015
1 parent 06ba2dc commit f2c3b68
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 6 deletions.
2 changes: 2 additions & 0 deletions src/NavItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const NavItem = React.createClass({
title,
target,
children,
tabIndex, //eslint-disable-line
'aria-controls': ariaControls,
...props } = this.props;
let classes = {
Expand All @@ -47,6 +48,7 @@ const NavItem = React.createClass({
href,
title,
target,
tabIndex,
id: linkId,
onClick: this.handleClick
};
Expand Down
98 changes: 92 additions & 6 deletions src/Tabs.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import classNames from 'classnames';
import React, { cloneElement } from 'react';
import React, { cloneElement, findDOMNode } from 'react';

import Col from './Col';
import Nav from './Nav';
import NavItem from './NavItem';
import styleMaps from './styleMaps';

import keycode from 'keycode';
import createChainedFunction from './utils/createChainedFunction';
import ValidComponentChildren from './utils/ValidComponentChildren';

let paneId = (props, child) => child.props.id ? child.props.id : props.id && (props.id + '___pane___' + child.props.eventKey);
let tabId = (props, child) => child.props.id ? child.props.id + '___tab' : props.id && (props.id + '___tab___' + child.props.eventKey);

let findChild = ValidComponentChildren.find;

function getDefaultActiveKeyFromChildren(children) {
let defaultActiveKey;

Expand All @@ -23,6 +26,30 @@ function getDefaultActiveKeyFromChildren(children) {
return defaultActiveKey;
}

function move(children, currentKey, keys, moveNext) {
let lastIdx = keys.length - 1;
let stopAt = keys[moveNext ? Math.max(lastIdx, 0) : 0];
let nextKey = currentKey;

function getNext() {
let idx = keys.indexOf(nextKey);
nextKey = moveNext
? keys[Math.min(lastIdx, idx + 1)]
: keys[Math.max(0, idx - 1)];

return findChild(children,
_child => _child.props.eventKey === nextKey);
}

let next = getNext();

while (next.props.eventKey !== stopAt && next.props.disabled) {
next = getNext();
}

return next.props.disabled ? currentKey : next.props.eventKey;
}

const Tabs = React.createClass({
propTypes: {
activeKey: React.PropTypes.any,
Expand Down Expand Up @@ -103,6 +130,22 @@ const Tabs = React.createClass({
}
},

componentDidUpdate() {
let tabs = this._tabs;
let tabIdx = this._eventKeys().indexOf(this.getActiveKey());

if (this._needsRefocus) {
this._needsRefocus = false;
if (tabs && tabIdx !== -1) {
let tabNode = findDOMNode(tabs[tabIdx]);

if (tabNode) {
tabNode.firstChild.focus();
}
}
}
},

handlePaneAnimateOutEnd() {
this.setState({
previousActiveKey: null
Expand Down Expand Up @@ -223,20 +266,23 @@ const Tabs = React.createClass({
);
},

renderTab(child) {
renderTab(child, index) {
if (child.props.title == null) {
return null;
}

let {eventKey, title, disabled} = child.props;
let { eventKey, title, disabled, onKeyDown, tabIndex = 0 } = child.props;
let isActive = this.getActiveKey() === eventKey;

return (
<NavItem
linkId={tabId(this.props, child)}
ref={'tab' + eventKey}
ref={ref => (this._tabs || (this._tabs = []))[index] = ref}
aria-controls={paneId(this.props, child)}
onKeyDown={createChainedFunction(this.handleKeyDown, onKeyDown)}
eventKey={eventKey}
disabled={disabled}>
tabIndex={isActive ? tabIndex : -1}
disabled={disabled }>
{title}
</NavItem>
);
Expand Down Expand Up @@ -286,6 +332,46 @@ const Tabs = React.createClass({
previousActiveKey
});
}
},

handleKeyDown(event) {
let keys = this._eventKeys();
let currentKey = this.getActiveKey() || keys[0];
let next;

switch (event.keyCode) {

case keycode.codes.left:
case keycode.codes.up:
next = move(this.props.children, currentKey, keys, false);

if (next && next !== currentKey) {
event.preventDefault();
this.handleSelect(next);
this._needsRefocus = true;
}
break;
case keycode.codes.right:
case keycode.codes.down:
next = move(this.props.children, currentKey, keys, true);

if (next && next !== currentKey) {
event.preventDefault();
this.handleSelect(next);
this._needsRefocus = true;
}
break;
default:
}
},

_eventKeys() {
let keys = [];

ValidComponentChildren.forEach(this.props.children,
({props: { eventKey }}) => keys.push(eventKey));

return keys;
}
});

Expand Down
13 changes: 13 additions & 0 deletions src/utils/ValidComponentChildren.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,22 @@ function hasValidComponent(children) {
return hasValid;
}

function find(children, finder) {
let child;

forEachValidComponents(children, (c, idx)=> {
if (!child && finder(c, idx, children)) {
child = c;
}
});

return child;
}

export default {
map: mapValidComponents,
forEach: forEachValidComponents,
numberOf: numberOfValidComponents,
find,
hasValidComponent
};
14 changes: 14 additions & 0 deletions test/NavItemSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ describe('NavItem', function () {
assert.ok(!React.findDOMNode(instance).hasAttribute('title'));
});

it('Should pass tabIndex to the anchor', () => {
let instance = ReactTestUtils.renderIntoDocument(
<NavItem href='/hi' tabIndex='3' title='boom!'>
Item content
</NavItem>
);

let node = React.findDOMNode(instance);

expect(node.hasAttribute('tabindex')).to.equal(false);
expect(node.firstChild.getAttribute('tabindex')).to.equal('3');

});

it('Should call `onSelect` when item is selected', function (done) {
function handleSelect(key) {
assert.equal(key, '2');
Expand Down
58 changes: 58 additions & 0 deletions test/TabsSpec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import ReactTestUtils from 'react/lib/ReactTestUtils';
import keycode from 'keycode';

import Col from '../src/Col';
import Nav from '../src/Nav';
Expand Down Expand Up @@ -432,6 +433,63 @@ describe('Tabs', function () {
checkTabRemovingWithAnimation(false);
});

describe('keyboard navigation', function() {
let instance;

beforeEach(function() {
instance = render(
<Tabs defaultActiveKey={1} id='tabs'>
<Tab id='pane-1' title="Tab 1" eventKey={1}>Tab 1 content</Tab>
<Tab id='pane-2' title="Tab 2" eventKey={2} disabled>Tab 2 content</Tab>
<Tab id='pane-2' title="Tab 3" eventKey={3}>Tab 3 content</Tab>
</Tabs>
, document.body);
});

afterEach(function() {
instance = React.unmountComponentAtNode(document.body);
});

it('only the active tab should be focusable', () => {
let tabs = ReactTestUtils.scryRenderedComponentsWithType(instance, NavItem);

expect(React.findDOMNode(tabs[0]).firstChild.getAttribute('tabindex')).to.equal('0');

expect(React.findDOMNode(tabs[1]).firstChild.getAttribute('tabindex')).to.equal('-1');
expect(React.findDOMNode(tabs[2]).firstChild.getAttribute('tabindex')).to.equal('-1');
});

it('should focus the next tab on arrow key', () => {
let tabs = ReactTestUtils.scryRenderedComponentsWithType(instance, NavItem);

let firstAnchor = React.findDOMNode(tabs[0]).firstChild;
let lastAnchor = React.findDOMNode(tabs[2]).firstChild; // skip disabled

firstAnchor.focus();

ReactTestUtils.Simulate.keyDown(firstAnchor, { keyCode: keycode('right') });

expect(instance.getActiveKey() === 2);
expect(document.activeElement).to.equal(lastAnchor);
});

it('should focus the previous tab on arrow key', () => {
instance.setState({ activeKey: 3 });

let tabs = ReactTestUtils.scryRenderedComponentsWithType(instance, NavItem);

let firstAnchor = React.findDOMNode(tabs[0]).firstChild;
let lastAnchor = React.findDOMNode(tabs[2]).firstChild;

lastAnchor.focus();

ReactTestUtils.Simulate.keyDown(lastAnchor, { keyCode: keycode('left') });

expect(instance.getActiveKey() === 2);
expect(document.activeElement).to.equal(firstAnchor);
});
});

describe('Web Accessibility', function() {
let instance;
beforeEach(function() {
Expand Down

0 comments on commit f2c3b68

Please sign in to comment.