From f2c3b683d9fd199cbaee60f21d34ea56d34a46e9 Mon Sep 17 00:00:00 2001 From: jquense Date: Fri, 28 Aug 2015 14:14:26 -0400 Subject: [PATCH] [changed] tab keyboard navigation to be more inline with ARIA spec http://www.w3.org/TR/wai-aria-practices/#tabpanel --- src/NavItem.js | 2 + src/Tabs.js | 98 +++++++++++++++++++++++++++-- src/utils/ValidComponentChildren.js | 13 ++++ test/NavItemSpec.js | 14 +++++ test/TabsSpec.js | 58 +++++++++++++++++ 5 files changed, 179 insertions(+), 6 deletions(-) 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() {