diff --git a/README.md b/README.md index c85afe64ee..d210b4d0ff 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,12 @@ var TabList = ReactTabs.TabList; var TabPanel = ReactTabs.TabPanel; var App = React.createClass({ - handleSelect: function (index, last) { - console.log('Selected tab: ' + index + ', Last tab: ' + last); + getInitialState: function () { + return { selectedTab: 2 }; + }, + + handleSelect: function (index) { + this.setState({ selectedTab: index }); }, render: function () { @@ -33,11 +37,17 @@ var App = React.createClass({ {/* is a composite component and acts as the main container. - `onSelect` is called whenever a tab is selected. The handler for - this function will be passed the current index as well as the last index. + `selectedIndex` is the currently selected tab. + + `onSelect` is a callback invoked whenever a user clicks on or + keyboard-navigates to a tab. It is passed the index of the selected tab. - `selectedIndex` is the tab to select when first rendered. By default - the first (index 0) tab will be selected. + If you provide a `selectedIndex`, you should always provide an `onSelect` + (and vice versa) so that you can update the `selectedIndex` in response to + user interactions. If you pass neither of these props, then the component's + default "uncontrolled" behavior is to automatically update its selectedIndex + to whatever would be passed to the onSelect handler. I.e., it behaves as + you'd expect. `forceRenderTabPanel` By default this react-tabs will only render the selected tab's contents. Setting `forceRenderTabPanel` to `true` allows you to override the @@ -47,7 +57,7 @@ var App = React.createClass({ {/* diff --git a/examples/controlled/app.js b/examples/controlled/app.js new file mode 100644 index 0000000000..fa08993346 --- /dev/null +++ b/examples/controlled/app.js @@ -0,0 +1,51 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Tab, Tabs, TabList, TabPanel } from '../../lib/main'; + +const App = React.createClass({ + handleInputChange() { + this.forceUpdate(); + }, + + getInitialState() { + return {currentTab: 1}; + }, + + handleTabSelect(index) { + this.setState({currentTab: index}); + }, + + render() { + return ( +
+ + + + First + Second + + +

This is the first tab, but not the default.

+
+ +

This is the default tab.

+ +
+
+
+ ); + }, + + swapTab() { + this.setState({currentTab: (this.state.currentTab + 1) % 2}); + } +}); + +ReactDOM.render(, document.getElementById('example')); diff --git a/examples/controlled/index.html b/examples/controlled/index.html new file mode 100644 index 0000000000..1507df3939 --- /dev/null +++ b/examples/controlled/index.html @@ -0,0 +1,8 @@ + + +React Tabs + +
+ + + diff --git a/lib/components/Tab.js b/lib/components/Tab.js index 51090143ed..f8e5478b1c 100644 --- a/lib/components/Tab.js +++ b/lib/components/Tab.js @@ -3,12 +3,11 @@ import { findDOMNode } from 'react-dom'; import cx from 'classnames'; function syncNodeAttributes(node, props) { + // tabindex and selected misbehave when trying to set them like other JSX + // attributes. see https://github.com/facebook/react/issues/2528 if (props.selected) { node.setAttribute('tabindex', 0); node.setAttribute('selected', 'selected'); - if (props.focus) { - node.focus(); - } } else { node.removeAttribute('tabindex'); node.removeAttribute('selected'); @@ -33,7 +32,6 @@ module.exports = React.createClass({ getDefaultProps() { return { - focus: false, selected: false, id: null, panelId: null diff --git a/lib/components/Tabs.js b/lib/components/Tabs.js index ad99d53fbe..d98f41838b 100644 --- a/lib/components/Tabs.js +++ b/lib/components/Tabs.js @@ -4,6 +4,8 @@ import cx from 'classnames'; import jss from 'js-stylesheet'; import uuid from '../helpers/uuid'; import childrenPropType from '../helpers/childrenPropType'; +import onSelectPropType from '../helpers/onSelectPropType'; +import selectedIndexPropType from '../helpers/selectedIndexPropType'; // Determine if a node from event.target is a Tab element function isTabNode(node) { @@ -22,9 +24,8 @@ module.exports = React.createClass({ propTypes: { className: PropTypes.string, - selectedIndex: PropTypes.number, - onSelect: PropTypes.func, - focus: PropTypes.bool, + selectedIndex: selectedIndexPropType, + onSelect: onSelectPropType, children: childrenPropType, forceRenderTabPanel: PropTypes.bool }, @@ -41,14 +42,17 @@ module.exports = React.createClass({ getDefaultProps() { return { - selectedIndex: -1, - focus: false, forceRenderTabPanel: false }; }, getInitialState() { - return this.copyPropsToState(this.props); + return this.isControlledComponent() ? { + needsFocus: false + } : { + needsFocus: false, + selectedIndex: 0 + }; }, getChildContext() { @@ -63,8 +67,12 @@ module.exports = React.createClass({ } }, - componentWillReceiveProps(newProps) { - this.setState(this.copyPropsToState(newProps)); + componentDidUpdate() { + if (this.state.needsFocus) { + // we don't want this to trigger a re-render, so we avoid setState here + this.state.needsFocus = false; + this.focusSelectedTab(); + } }, handleClick(e) { @@ -84,7 +92,7 @@ module.exports = React.createClass({ handleKeyDown(e) { if (isTabNode(e.target)) { - let index = this.state.selectedIndex; + let index = this.getSelectedIndex(); let preventDefault = false; // Select next tab to the left @@ -110,67 +118,42 @@ module.exports = React.createClass({ setSelected(index, focus) { // Don't do anything if nothing has changed - if (index === this.state.selectedIndex) return; - // Check index boundary - if (index < 0 || index >= this.getTabsCount()) return; - - // Keep reference to last index for event handler - const last = this.state.selectedIndex; + if (index === this.getSelectedIndex()) return; - // Update selected index - this.setState({ selectedIndex: index, focus: focus === true }); + // mark whether focus is needed after next re-render + this.setState({ needsFocus: !!focus }); // Call change event handler - if (typeof this.props.onSelect === 'function') { - this.props.onSelect(index, last); + if (this.isControlledComponent()) { + this.props.onSelect(index); + } else { // default "uncontrolled component" behavior + this.setState({ selectedIndex: index }); } }, - getNextTab(index) { - const count = this.getTabsCount(); - - // Look for non-disabled tab from index to the last tab on the right - for (let i = index + 1; i < count; i++) { - const tab = this.getTab(i); - if (!isTabDisabled(findDOMNode(tab))) { - return i; - } - } + getSelectedIndex() { + const authority = this.isControlledComponent() ? this.props : this.state; + return authority.selectedIndex; + }, - // If no tab found, continue searching from first on left to index - for (let i = 0; i < index; i++) { - const tab = this.getTab(i); - if (!isTabDisabled(findDOMNode(tab))) { - return i; + // from `index`, step thru tabs looking for a non-disabled one, and + // return the index of the first one you find. if `increment` is 1, + // step towards the right. if it's -1, step towards the left. + getNextTab(index, increment = 1) { + const count = this.getTabsCount(); + let nextIndex; + let delta = count + increment; + for (let i = 0; i < count; i++, delta += increment) { + nextIndex = (index + delta) % count; + if (!isTabDisabled(findDOMNode(this.getTab(nextIndex)))) { + break; } } - - // No tabs are disabled, return index - return index; + return nextIndex; }, getPrevTab(index) { - let i = index; - - // Look for non-disabled tab from index to first tab on the left - while (i--) { - const tab = this.getTab(i); - if (!isTabDisabled(findDOMNode(tab))) { - return i; - } - } - - // If no tab found, continue searching from last tab on right to index - i = this.getTabsCount(); - while (i-- > index) { - const tab = this.getTab(i); - if (!isTabDisabled(findDOMNode(tab))) { - return i; - } - } - - // No tabs are disabled, return index - return index; + return this.getNextTab(index, -1); }, getTabsCount() { @@ -199,7 +182,6 @@ module.exports = React.createClass({ let index = 0; let count = 0; const children = this.props.children; - const state = this.state; const tabIds = this.tabIds = this.tabIds || []; const panelIds = this.panelIds = this.panelIds || []; let diff = this.tabIds.length - this.getTabsCount(); @@ -237,8 +219,7 @@ module.exports = React.createClass({ const ref = 'tabs-' + index; const id = tabIds[index]; const panelId = panelIds[index]; - const selected = state.selectedIndex === index; - const focus = selected && state.focus; + const selected = this.getSelectedIndex() === index; index++; @@ -246,8 +227,7 @@ module.exports = React.createClass({ ref, id, panelId, - selected, - focus + selected }); }) }); @@ -260,7 +240,7 @@ module.exports = React.createClass({ const ref = 'panels-' + index; const id = panelIds[index]; const tabId = tabIds[index]; - const selected = state.selectedIndex === index; + const selected = this.getSelectedIndex() === index; index++; @@ -277,25 +257,6 @@ module.exports = React.createClass({ }, render() { - // This fixes an issue with focus management. - // - // Ultimately, when focus is true, and an input has focus, - // and any change on that input causes a state change/re-render, - // focus gets sent back to the active tab, and input loses focus. - // - // Since the focus state only needs to be remembered - // for the current render, we can reset it once the - // render has happened. - // - // Don't use setState, because we don't want to re-render. - // - // See https://github.com/rackt/react-tabs/pull/7 - if (this.state.focus) { - setTimeout(() => { - this.state.focus = false; - }, 0); - } - return (
{} + })); + + assertTabSelected(tabs, 1); + }); + }); + + describe('when onSelect is omitted', function() { + it('should ignore the prop', function() { + const tabs = TestUtils.renderIntoDocument(createTabs({ + selectedIndex: 1 + })); + + assertTabSelected(tabs, 0); + }); + }); }); - it('should honor selectedIndex prop', function() { - const tabs = TestUtils.renderIntoDocument(createTabs({selectedIndex: 1})); - - assertTabSelected(tabs, 1); - }); - - it('should call onSelect when selection changes', function() { - const called = {index: -1, last: -1}; - const tabs = TestUtils.renderIntoDocument(createTabs({ - onSelect: function(index, last) { - called.index = index; - called.last = last; - } - })); - - tabs.setSelected(2); - equal(called.index, 2); - equal(called.last, 0); + describe('onSelect', function() { + describe('when selectedIndex is present', function() { + it('should invoke the callback when selection changes', function() { + const called = {index: null}; + const tabs = TestUtils.renderIntoDocument(createTabs({ + selectedIndex: 1, + onSelect: (index) => { called.index = index; } + })); + + tabs.setSelected(2); + equal(called.index, 2); + }); + }); + + describe('when selectedIndex is omitted', function() { + it('should ignore the callback when selection changes', function() { + const called = {index: null}; + const tabs = TestUtils.renderIntoDocument(createTabs({ + onSelect: (index) => { called.index = index; } + })); + + tabs.setSelected(2); + equal(called.index, null); + }); + }); }); it('should have a default className', function() { @@ -101,56 +124,119 @@ describe('react-tabs', function() { }); describe('interaction', function() { - it('should update selectedIndex when clicked', function() { - const tabs = TestUtils.renderIntoDocument(createTabs()); - - TestUtils.Simulate.click(findDOMNode(tabs.getTab(1))); - assertTabSelected(tabs, 1); - }); - - it('should update selectedIndex when tab child is clicked', function() { - const tabs = TestUtils.renderIntoDocument(createTabs()); - - TestUtils.Simulate.click(findDOMNode(tabs.getTab(2)).firstChild); - assertTabSelected(tabs, 2); + describe('as an uncontrolled component', function() { + it('should default to selecting the first tab', function() { + const tabs = TestUtils.renderIntoDocument(createTabs()); + + assertTabSelected(tabs, 0); + }); + + it('should update selected tab when clicked', function() { + const tabs = TestUtils.renderIntoDocument(createTabs()); + + TestUtils.Simulate.click(findDOMNode(tabs.getTab(1))); + assertTabSelected(tabs, 1); + }); + + it('should update selected tab when tab child is clicked', function() { + const tabs = TestUtils.renderIntoDocument(createTabs()); + + TestUtils.Simulate.click(findDOMNode(tabs.getTab(2)).firstChild); + assertTabSelected(tabs, 2); + }); + + it('should not change selected tab when clicking a disabled tab', function() { + const tabs = TestUtils.renderIntoDocument(createTabs({selectedIndex: 0})); + + TestUtils.Simulate.click(findDOMNode(tabs.getTab(3))); + assertTabSelected(tabs, 0); + }); + + // TODO: Can't seem to make this fail when removing fix :`( + // See https://github.com/rackt/react-tabs/pull/7 + // it('should preserve selectedIndex when typing', function () { + // let App = React.createClass({ + // handleKeyDown: function () { this.forceUpdate(); }, + // render: function () { + // return ( + // + // + // First + // Second + // + // 1st + // + // + // ); + // } + // }); + // + // let tabs = TestUtils.renderIntoDocument().refs.tabs; + // let input = tabs.getDOMNode().querySelector('input'); + // + // input.focus(); + // TestUtils.Simulate.keyDown(input, { + // keyCode: 'a'.charCodeAt() + // }); + // + // assertTabSelected(tabs, 1); + // }); }); - it('should not change selectedIndex when clicking a disabled tab', function() { - const tabs = TestUtils.renderIntoDocument(createTabs({selectedIndex: 0})); - - TestUtils.Simulate.click(findDOMNode(tabs.getTab(3))); - assertTabSelected(tabs, 0); + describe('as a controlled component', function() { + it('should call onSelect when a tab is clicked', function() { + const called = {index: null}; + const tabs = TestUtils.renderIntoDocument(createTabs({ + selectedIndex: 0, + onSelect: function(index) { + called.index = index; + } + })); + + TestUtils.Simulate.click(findDOMNode(tabs.getTab(1))); + equal(called.index, 1); + }); + + it('should not call onSelect when a disabled tab is clicked', function() { + const called = {index: null}; + const tabs = TestUtils.renderIntoDocument(createTabs({ + selectedIndex: 0, + onSelect: function(index) { + called.index = index; + } + })); + + TestUtils.Simulate.click(findDOMNode(tabs.getTab(3))); + equal(called.index, null); + }); + + it('should not automatically update selected tab when clicked', function() { + const tabs = TestUtils.renderIntoDocument(createTabs({ + selectedIndex: 0, + onSelect: () => {} + })); + TestUtils.Simulate.click(findDOMNode(tabs.getTab(1))); + + assertTabSelected(tabs, 0); + }); + + it('should update selected tab when selectedIndex changes', function() { + const container = document.createElement('div'); + const tabs = render(createTabs({ + selectedIndex: 1, + onSelect: () => {} + }), container); + assertTabSelected(tabs, 1); + + render(createTabs({ + selectedIndex: 0, + onSelect: () => {} + }), container); + assertTabSelected(tabs, 0); + + unmountComponentAtNode(container); + }); }); - - // TODO: Can't seem to make this fail when removing fix :`( - // See https://github.com/rackt/react-tabs/pull/7 - // it('should preserve selectedIndex when typing', function () { - // let App = React.createClass({ - // handleKeyDown: function () { this.forceUpdate(); }, - // render: function () { - // return ( - // - // - // First - // Second - // - // 1st - // - // - // ); - // } - // }); - // - // let tabs = TestUtils.renderIntoDocument().refs.tabs; - // let input = tabs.getDOMNode().querySelector('input'); - // - // input.focus(); - // TestUtils.Simulate.keyDown(input, { - // keyCode: 'a'.charCodeAt() - // }); - // - // assertTabSelected(tabs, 1); - // }); }); describe('performance', function() { @@ -181,6 +267,24 @@ describe('react-tabs', function() { }); describe('validation', function() { + it('should result with warning when selectedIndex is present but onSelect is missing', function() { + const tabs = TestUtils.renderIntoDocument( + + ); + + const result = Tabs.propTypes.selectedIndex(tabs.props, 'children', 'Tabs'); + ok(result instanceof Error); + }); + + it('should result with warning when onSelect is present but selectedIndex is missing', function() { + const tabs = TestUtils.renderIntoDocument( + {}}/> + ); + + const result = Tabs.propTypes.onSelect(tabs.props, 'children', 'Tabs'); + ok(result instanceof Error); + }); + it('should result with warning when tabs/panels are imbalanced', function() { const tabs = TestUtils.renderIntoDocument( diff --git a/lib/helpers/onSelectPropType.js b/lib/helpers/onSelectPropType.js new file mode 100644 index 0000000000..cc797e3f30 --- /dev/null +++ b/lib/helpers/onSelectPropType.js @@ -0,0 +1,20 @@ +import React from 'react'; + +module.exports = function onSelectPropType(props) { + // onSelect is optional + if (!('onSelect' in props)) { + return null; + } + + // onSelect, if present, must be a function + const error = React.PropTypes.func.apply(null, arguments); + if (error) { + return error; + } + + if (typeof props.selectedIndex !== 'number') { + return new Error('`onSelect` must be accompanied by a numeric `selectedIndex`'); + } + + return null; +}; diff --git a/lib/helpers/selectedIndexPropType.js b/lib/helpers/selectedIndexPropType.js new file mode 100644 index 0000000000..a46446b083 --- /dev/null +++ b/lib/helpers/selectedIndexPropType.js @@ -0,0 +1,18 @@ +import React from 'react'; + +module.exports = function selectedIndexPropType(props) { + // selectedIndex is optional + if (!('selectedIndex' in props)) { + return null; + } + + // selectedIndex, if present, must be a number + const error = React.PropTypes.number.apply(null, arguments); + if (error) { + return error; + } + + if (typeof props.onSelect !== 'function') { + return new Error('`selectedIndex` must be accompanied by a function `onSelect`'); + } +};