Skip to content

Add nested TabList and TabPanel support #184

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

Merged
merged 3 commits into from
Jun 13, 2017
Merged
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
21 changes: 3 additions & 18 deletions src/components/UncontrolledTabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Tab from './Tab';
import TabList from './TabList';
import TabPanel from './TabPanel';
import { getPanelsCount, getTabsCount } from '../helpers/count';
import { deepMap } from '../helpers/childrenDeepMap';

// Determine if a node from event.target is a Tab element
function isTabNode(node) {
Expand Down Expand Up @@ -134,13 +135,7 @@ export default class UncontrolledTabs extends Component {
}

// Map children to dynamically setup refs
return React.Children.map(children, child => {
// null happens when conditionally rendering TabPanel/Tab
// see https://github.com/reactjs/react-tabs/issues/37
if (child === null) {
return null;
}

return deepMap(children, child => {
let result = child;

// Clone TabList and Tab components to have refs
Expand All @@ -159,17 +154,7 @@ export default class UncontrolledTabs extends Component {
}

result = cloneElement(child, {
children: React.Children.map(child.props.children, tab => {
// null happens when conditionally rendering TabPanel/Tab
// see https://github.com/reactjs/react-tabs/issues/37
if (tab === null) {
return null;
}

// Exit early if this is not a tab. That way we can have arbitrary
// elements anywhere inside <TabList>
if (tab.type !== Tab) return tab;

children: deepMap(child.props.children, tab => {
const key = `tabs-${listIndex}`;
const selected = selectedIndex === listIndex;

Expand Down
88 changes: 59 additions & 29 deletions src/components/__tests__/Tabs-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,48 +141,57 @@ describe('<Tabs />', () => {
test('should render all tabs if forceRenderTabPanel is true', () => {
expectToMatchSnapshot(createTabs({ forceRenderTabPanel: true }));
});
});

test('should not clone non tabs element', () => {
class Demo extends React.Component {
render() {
const arbitrary1 = <div ref="arbitrary1">One</div>; // eslint-disable-line react/no-string-refs
const arbitrary2 = <span ref="arbitrary2">Two</span>; // eslint-disable-line react/no-string-refs
const arbitrary3 = <small ref="arbitrary3">Three</small>; // eslint-disable-line react/no-string-refs

return (
<Tabs>
<TabList>
{arbitrary1}
<Tab>Foo</Tab>
{arbitrary2}
<Tab>Bar</Tab>
{arbitrary3}
</TabList>
describe('validation', () => {
test('should result with warning when tabs/panels are imbalanced', () => {
const oldConsoleError = console.error; // eslint-disable-line no-console
console.error = () => {}; // eslint-disable-line no-console
const wrapper = shallow(
<Tabs>
<TabList>
<Tab>Foo</Tab>
</TabList>
</Tabs>,
);
console.error = oldConsoleError; // eslint-disable-line no-console

<TabPanel>Hello Baz</TabPanel>
<TabPanel>Hello Faz</TabPanel>
</Tabs>
);
}
}
const result = Tabs.propTypes.children(wrapper.props(), 'children', 'Tabs');
expect(result instanceof Error).toBe(true);
});

const wrapper = mount(<Demo />);
test('should result with warning when tab outside of tablist', () => {
const oldConsoleError = console.error; // eslint-disable-line no-console
console.error = () => {}; // eslint-disable-line no-console
const wrapper = shallow(
<Tabs>
<TabList>
<Tab>Foo</Tab>
</TabList>
<Tab>Foo</Tab>
<TabPanel />
<TabPanel />
</Tabs>,
);
console.error = oldConsoleError; // eslint-disable-line no-console

expect(wrapper.ref('arbitrary1').text()).toBe('One');
expect(wrapper.ref('arbitrary2').text()).toBe('Two');
expect(wrapper.ref('arbitrary3').text()).toBe('Three');
const result = Tabs.propTypes.children(wrapper.props(), 'children', 'Tabs');
expect(result instanceof Error).toBe(true);
});
});

describe('validation', () => {
test('should result with warning when tabs/panels are imbalanced', () => {
test('should result with warning when multiple tablist components exist', () => {
const oldConsoleError = console.error; // eslint-disable-line no-console
console.error = () => {}; // eslint-disable-line no-console
const wrapper = shallow(
<Tabs>
<TabList>
<Tab>Foo</Tab>
</TabList>
<TabList>
<Tab>Foo</Tab>
</TabList>
<TabPanel />
<TabPanel />
</Tabs>,
);
console.error = oldConsoleError; // eslint-disable-line no-console
Expand Down Expand Up @@ -341,6 +350,27 @@ describe('<Tabs />', () => {
assertTabSelected(wrapper, 0);
assertTabSelected(innerTabs, 1);
});

test('should allow other DOM nodes', () => {
expectToMatchSnapshot(
<Tabs>
<div id="tabs-nav-wrapper">
<button>Left</button>
<div className="tabs-container">
<TabList>
<Tab />
<Tab />
</TabList>
</div>
<button>Right</button>
</div>
<div className="tab-panels">
<TabPanel />
<TabPanel />
</div>
</Tabs>,
);
});
});

test('should pass through custom properties', () => {
Expand Down
65 changes: 65 additions & 0 deletions src/components/__tests__/__snapshots__/Tabs-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,71 @@ exports[`<Tabs /> should pass through custom properties 1`] = `
/>
`;

exports[`<Tabs /> validation should allow other DOM nodes 1`] = `
<div
className="react-tabs"
data-tabs={true}
onClick={[Function]}
onKeyDown={[Function]}
>
<div
id="tabs-nav-wrapper"
>
<button>
Left
</button>
<div
className="tabs-container"
>
<ul
className="react-tabs__tab-list"
role="tablist"
>
<li
aria-controls="react-tabs-1"
aria-disabled="false"
aria-selected="true"
className="react-tabs__tab react-tabs__tab--selected"
id="react-tabs-0"
role="tab"
tabIndex="0"
/>
<li
aria-controls="react-tabs-3"
aria-disabled="false"
aria-selected="false"
className="react-tabs__tab"
id="react-tabs-2"
role="tab"
tabIndex={null}
/>
</ul>
</div>
<button>
Right
</button>
</div>
<div
className="tab-panels"
>
<div
aria-labelledby="react-tabs-0"
className="react-tabs__tab-panel react-tabs__tab-panel--selected"
id="react-tabs-1"
role="tabpanel"
style={Object {}}
/>
<div
aria-labelledby="react-tabs-2"
className="react-tabs__tab-panel"
id="react-tabs-3"
role="tabpanel"
style={Object {}}
/>
</div>
</div>
`;

exports[`<Tabs /> validation should allow random order for elements 1`] = `
<div
className="react-tabs"
Expand Down
45 changes: 45 additions & 0 deletions src/helpers/childrenDeepMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Children, cloneElement } from 'react';
import Tab from '../components/Tab';
import TabList from '../components/TabList';
import TabPanel from '../components/TabPanel';

function isTabChild(child) {
return child.type === Tab || child.type === TabList || child.type === TabPanel;
}

export function deepMap(children, callback) {
return Children.map(children, child => {
// null happens when conditionally rendering TabPanel/Tab
// see https://github.com/reactjs/react-tabs/issues/37
if (child === null) return null;

if (isTabChild(child)) {
return callback(child);
}

if (child.props && child.props.children && typeof child.props.children === 'object') {
// Clone the child that has children and map them too
return cloneElement(child, {
...child.props,
children: deepMap(child.props.children, callback),
});
}

return child;
});
}

export function deepForEach(children, callback) {
return Children.forEach(children, child => {
// null happens when conditionally rendering TabPanel/Tab
// see https://github.com/reactjs/react-tabs/issues/37
if (child === null) return;

if (child.type === Tab || child.type === TabPanel) {
callback(child);
} else if (child.props && child.props.children && typeof child.props.children === 'object') {
if (child.type === TabList) callback(child);
deepForEach(child.props.children, callback);
}
});
}
23 changes: 12 additions & 11 deletions src/helpers/count.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import React from 'react';
import TabList from '../components/TabList';
import { deepForEach } from '../helpers/childrenDeepMap';
import Tab from '../components/Tab';
import TabPanel from '../components/TabPanel';

export function getTabsCount(children) {
const tabLists = React.Children.toArray(children).filter(x => x.type === TabList);
let tabCount = 0;
deepForEach(children, child => {
if (child.type === Tab) tabCount++;
});

if (tabLists[0] && tabLists[0].props.children) {
return React.Children.count(
React.Children.toArray(tabLists[0].props.children).filter(x => x.type === Tab),
);
}

return 0;
return tabCount;
}

export function getPanelsCount(children) {
return React.Children.count(React.Children.toArray(children).filter(x => x.type === TabPanel));
let panelCount = 0;
deepForEach(children, child => {
if (child.type === TabPanel) panelCount++;
});

return panelCount;
}
46 changes: 22 additions & 24 deletions src/helpers/propTypes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import { deepForEach } from '../helpers/childrenDeepMap';
import Tab from '../components/Tab';
import TabList from '../components/TabList';
import TabPanel from '../components/TabPanel';
Expand All @@ -7,38 +7,36 @@ export function childrenPropType(props, propName, componentName) {
let error;
let tabsCount = 0;
let panelsCount = 0;
let tabListFound = false;
const listTabs = [];
const children = props[propName];

React.Children.forEach(children, child => {
// null happens when conditionally rendering TabPanel/Tab
// see https://github.com/reactjs/react-tabs/issues/37
if (child === null) {
return;
}

deepForEach(children, child => {
if (child.type === TabList) {
React.Children.forEach(child.props.children, c => {
// null happens when conditionally rendering TabPanel/Tab
// see https://github.com/reactjs/react-tabs/issues/37
if (c === null) {
return;
}
if (child.props && child.props.children && typeof child.props.children === 'object') {
deepForEach(child.props.children, listChild => listTabs.push(listChild));
}

if (c.type === Tab) {
tabsCount++;
}
});
if (tabListFound) {
error = new Error(
"Found multiple 'TabList' components inside 'Tabs'. Only one is allowed.",
);
}
tabListFound = true;
}
if (child.type === Tab) {
if (!tabListFound || listTabs.indexOf(child) === -1) {
error = new Error(
"Found a 'Tab' component outside of the 'TabList' component. 'Tab' components have to be inside the 'TabList' component.",
);
}
tabsCount++;
} else if (child.type === TabPanel) {
panelsCount++;
} else {
error = new Error(
`Expected 'TabList' or 'TabPanel' but found '${child.type.displayName ||
child.type}' in \`${componentName}\``,
);
}
});

if (tabsCount !== panelsCount) {
if (!error && tabsCount !== panelsCount) {
error = new Error(
`There should be an equal number of 'Tab' and 'TabPanel' in \`${componentName}\`.` +
`Received ${tabsCount} 'Tab' and ${panelsCount} 'TabPanel'.`,
Expand Down