diff --git a/src/NavItem.js b/src/NavItem.js
index 748c65f260..0ef949fb08 100644
--- a/src/NavItem.js
+++ b/src/NavItem.js
@@ -36,6 +36,7 @@ const NavItem = React.createClass({
title,
target,
children,
+ tabIndex, //eslint-disable-line
'aria-controls': ariaControls,
...props } = this.props;
let classes = {
@@ -47,6 +48,7 @@ const NavItem = React.createClass({
href,
title,
target,
+ tabIndex,
id: linkId,
onClick: this.handleClick
};
diff --git a/src/Tabs.js b/src/Tabs.js
index edd6c56862..a6b2809127 100644
--- a/src/Tabs.js
+++ b/src/Tabs.js
@@ -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;
@@ -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,
@@ -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
@@ -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 (
(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}
);
@@ -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;
}
});
diff --git a/src/utils/ValidComponentChildren.js b/src/utils/ValidComponentChildren.js
index 1ec9384ffb..03a8896017 100644
--- a/src/utils/ValidComponentChildren.js
+++ b/src/utils/ValidComponentChildren.js
@@ -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
};
diff --git a/test/NavItemSpec.js b/test/NavItemSpec.js
index 6e665be08c..66a4d353a6 100644
--- a/test/NavItemSpec.js
+++ b/test/NavItemSpec.js
@@ -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(
+
+ Item content
+
+ );
+
+ 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');
diff --git a/test/TabsSpec.js b/test/TabsSpec.js
index 4cade1a2ca..1103b05898 100644
--- a/test/TabsSpec.js
+++ b/test/TabsSpec.js
@@ -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';
@@ -432,6 +433,63 @@ describe('Tabs', function () {
checkTabRemovingWithAnimation(false);
});
+ describe('keyboard navigation', function() {
+ let instance;
+
+ beforeEach(function() {
+ instance = render(
+
+ Tab 1 content
+ Tab 2 content
+ Tab 3 content
+
+ , 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() {