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

enable controlled component behavior #80

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,30 @@ 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 () {
return (
{/*
<Tabs/> 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
Expand All @@ -47,7 +57,7 @@ var App = React.createClass({

<Tabs
onSelect={this.handleSelect}
selectedIndex={2}
selectedIndex={this.state.selectedTab}
>

{/*
Expand Down
51 changes: 51 additions & 0 deletions examples/controlled/app.js
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{padding: 50}}>
<button style={{marginBottom: 20}}
onClick={this.swapTab}>Select other tab</button>
<Tabs
selectedIndex={this.state.currentTab}
onSelect={this.handleTabSelect}
>
<TabList>
<Tab>First</Tab>
<Tab>Second</Tab>
</TabList>
<TabPanel>
<p>This is the first tab, but not the default.</p>
</TabPanel>
<TabPanel>
<p>This is the default tab.</p>
<input
type="text"
onChange={this.handleInputChange}
/>
</TabPanel>
</Tabs>
</div>
);
},

swapTab() {
this.setState({currentTab: (this.state.currentTab + 1) % 2});
}
});

ReactDOM.render(<App/>, document.getElementById('example'));
8 changes: 8 additions & 0 deletions examples/controlled/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<meta charset="utf-8"/>
<title>React Tabs</title>
<body>
<div id="example"></div>
<script src="../__build__/shared.js"></script>
<script src="../__build__/controlled.js"></script>

6 changes: 2 additions & 4 deletions lib/components/Tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -33,7 +32,6 @@ module.exports = React.createClass({

getDefaultProps() {
return {
focus: false,
selected: false,
id: null,
panelId: null
Expand Down
157 changes: 51 additions & 106 deletions lib/components/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
},
Expand All @@ -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() {
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -237,17 +219,15 @@ 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++;

return cloneElement(tab, {
ref,
id,
panelId,
selected,
focus
selected
});
})
});
Expand All @@ -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++;

Expand All @@ -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 (
<div
className={cx(
Expand All @@ -311,29 +272,13 @@ module.exports = React.createClass({
);
},

// This is an anti-pattern, so sue me
copyPropsToState(props) {
let selectedIndex = props.selectedIndex;

// If no selectedIndex prop was supplied, then try
// preserving the existing selectedIndex from state.
// If the state has not selectedIndex, default
// to the first tab in the TabList.
//
// TODO: Need automation testing around this
// Manual testing can be done using examples/focus
// See 'should preserve selectedIndex when typing' in specs/Tabs.spec.js
if (selectedIndex === -1) {
if (this.state && this.state.selectedIndex) {
selectedIndex = this.state.selectedIndex;
} else {
selectedIndex = 0;
}
}
isControlledComponent() {
return (typeof this.props.selectedIndex === 'number' &&
typeof this.props.onSelect === 'function');
},

return {
selectedIndex: selectedIndex,
focus: props.focus
};
focusSelectedTab() {
const selectedTab = this.refs[`tabs-${this.getSelectedIndex()}`];
findDOMNode(selectedTab).focus();
}
});
Loading