Skip to content

Commit 430ed30

Browse files
authored
Add nested TabList and TabPanel support (#184)
* Added nested Tablist Support * Fixed dependency issue. * Tests for proptypes
1 parent 712c9e8 commit 430ed30

File tree

6 files changed

+206
-82
lines changed

6 files changed

+206
-82
lines changed

src/components/UncontrolledTabs.js

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Tab from './Tab';
77
import TabList from './TabList';
88
import TabPanel from './TabPanel';
99
import { getPanelsCount, getTabsCount } from '../helpers/count';
10+
import { deepMap } from '../helpers/childrenDeepMap';
1011

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

136137
// Map children to dynamically setup refs
137-
return React.Children.map(children, child => {
138-
// null happens when conditionally rendering TabPanel/Tab
139-
// see https://github.com/reactjs/react-tabs/issues/37
140-
if (child === null) {
141-
return null;
142-
}
143-
138+
return deepMap(children, child => {
144139
let result = child;
145140

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

161156
result = cloneElement(child, {
162-
children: React.Children.map(child.props.children, tab => {
163-
// null happens when conditionally rendering TabPanel/Tab
164-
// see https://github.com/reactjs/react-tabs/issues/37
165-
if (tab === null) {
166-
return null;
167-
}
168-
169-
// Exit early if this is not a tab. That way we can have arbitrary
170-
// elements anywhere inside <TabList>
171-
if (tab.type !== Tab) return tab;
172-
157+
children: deepMap(child.props.children, tab => {
173158
const key = `tabs-${listIndex}`;
174159
const selected = selectedIndex === listIndex;
175160

src/components/__tests__/Tabs-test.js

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -141,48 +141,57 @@ describe('<Tabs />', () => {
141141
test('should render all tabs if forceRenderTabPanel is true', () => {
142142
expectToMatchSnapshot(createTabs({ forceRenderTabPanel: true }));
143143
});
144+
});
144145

145-
test('should not clone non tabs element', () => {
146-
class Demo extends React.Component {
147-
render() {
148-
const arbitrary1 = <div ref="arbitrary1">One</div>; // eslint-disable-line react/no-string-refs
149-
const arbitrary2 = <span ref="arbitrary2">Two</span>; // eslint-disable-line react/no-string-refs
150-
const arbitrary3 = <small ref="arbitrary3">Three</small>; // eslint-disable-line react/no-string-refs
151-
152-
return (
153-
<Tabs>
154-
<TabList>
155-
{arbitrary1}
156-
<Tab>Foo</Tab>
157-
{arbitrary2}
158-
<Tab>Bar</Tab>
159-
{arbitrary3}
160-
</TabList>
146+
describe('validation', () => {
147+
test('should result with warning when tabs/panels are imbalanced', () => {
148+
const oldConsoleError = console.error; // eslint-disable-line no-console
149+
console.error = () => {}; // eslint-disable-line no-console
150+
const wrapper = shallow(
151+
<Tabs>
152+
<TabList>
153+
<Tab>Foo</Tab>
154+
</TabList>
155+
</Tabs>,
156+
);
157+
console.error = oldConsoleError; // eslint-disable-line no-console
161158

162-
<TabPanel>Hello Baz</TabPanel>
163-
<TabPanel>Hello Faz</TabPanel>
164-
</Tabs>
165-
);
166-
}
167-
}
159+
const result = Tabs.propTypes.children(wrapper.props(), 'children', 'Tabs');
160+
expect(result instanceof Error).toBe(true);
161+
});
168162

169-
const wrapper = mount(<Demo />);
163+
test('should result with warning when tab outside of tablist', () => {
164+
const oldConsoleError = console.error; // eslint-disable-line no-console
165+
console.error = () => {}; // eslint-disable-line no-console
166+
const wrapper = shallow(
167+
<Tabs>
168+
<TabList>
169+
<Tab>Foo</Tab>
170+
</TabList>
171+
<Tab>Foo</Tab>
172+
<TabPanel />
173+
<TabPanel />
174+
</Tabs>,
175+
);
176+
console.error = oldConsoleError; // eslint-disable-line no-console
170177

171-
expect(wrapper.ref('arbitrary1').text()).toBe('One');
172-
expect(wrapper.ref('arbitrary2').text()).toBe('Two');
173-
expect(wrapper.ref('arbitrary3').text()).toBe('Three');
178+
const result = Tabs.propTypes.children(wrapper.props(), 'children', 'Tabs');
179+
expect(result instanceof Error).toBe(true);
174180
});
175-
});
176181

177-
describe('validation', () => {
178-
test('should result with warning when tabs/panels are imbalanced', () => {
182+
test('should result with warning when multiple tablist components exist', () => {
179183
const oldConsoleError = console.error; // eslint-disable-line no-console
180184
console.error = () => {}; // eslint-disable-line no-console
181185
const wrapper = shallow(
182186
<Tabs>
183187
<TabList>
184188
<Tab>Foo</Tab>
185189
</TabList>
190+
<TabList>
191+
<Tab>Foo</Tab>
192+
</TabList>
193+
<TabPanel />
194+
<TabPanel />
186195
</Tabs>,
187196
);
188197
console.error = oldConsoleError; // eslint-disable-line no-console
@@ -341,6 +350,27 @@ describe('<Tabs />', () => {
341350
assertTabSelected(wrapper, 0);
342351
assertTabSelected(innerTabs, 1);
343352
});
353+
354+
test('should allow other DOM nodes', () => {
355+
expectToMatchSnapshot(
356+
<Tabs>
357+
<div id="tabs-nav-wrapper">
358+
<button>Left</button>
359+
<div className="tabs-container">
360+
<TabList>
361+
<Tab />
362+
<Tab />
363+
</TabList>
364+
</div>
365+
<button>Right</button>
366+
</div>
367+
<div className="tab-panels">
368+
<TabPanel />
369+
<TabPanel />
370+
</div>
371+
</Tabs>,
372+
);
373+
});
344374
});
345375

346376
test('should pass through custom properties', () => {

src/components/__tests__/__snapshots__/Tabs-test.js.snap

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,71 @@ exports[`<Tabs /> should pass through custom properties 1`] = `
660660
/>
661661
`;
662662

663+
exports[`<Tabs /> validation should allow other DOM nodes 1`] = `
664+
<div
665+
className="react-tabs"
666+
data-tabs={true}
667+
onClick={[Function]}
668+
onKeyDown={[Function]}
669+
>
670+
<div
671+
id="tabs-nav-wrapper"
672+
>
673+
<button>
674+
Left
675+
</button>
676+
<div
677+
className="tabs-container"
678+
>
679+
<ul
680+
className="react-tabs__tab-list"
681+
role="tablist"
682+
>
683+
<li
684+
aria-controls="react-tabs-1"
685+
aria-disabled="false"
686+
aria-selected="true"
687+
className="react-tabs__tab react-tabs__tab--selected"
688+
id="react-tabs-0"
689+
role="tab"
690+
tabIndex="0"
691+
/>
692+
<li
693+
aria-controls="react-tabs-3"
694+
aria-disabled="false"
695+
aria-selected="false"
696+
className="react-tabs__tab"
697+
id="react-tabs-2"
698+
role="tab"
699+
tabIndex={null}
700+
/>
701+
</ul>
702+
</div>
703+
<button>
704+
Right
705+
</button>
706+
</div>
707+
<div
708+
className="tab-panels"
709+
>
710+
<div
711+
aria-labelledby="react-tabs-0"
712+
className="react-tabs__tab-panel react-tabs__tab-panel--selected"
713+
id="react-tabs-1"
714+
role="tabpanel"
715+
style={Object {}}
716+
/>
717+
<div
718+
aria-labelledby="react-tabs-2"
719+
className="react-tabs__tab-panel"
720+
id="react-tabs-3"
721+
role="tabpanel"
722+
style={Object {}}
723+
/>
724+
</div>
725+
</div>
726+
`;
727+
663728
exports[`<Tabs /> validation should allow random order for elements 1`] = `
664729
<div
665730
className="react-tabs"

src/helpers/childrenDeepMap.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Children, cloneElement } from 'react';
2+
import Tab from '../components/Tab';
3+
import TabList from '../components/TabList';
4+
import TabPanel from '../components/TabPanel';
5+
6+
function isTabChild(child) {
7+
return child.type === Tab || child.type === TabList || child.type === TabPanel;
8+
}
9+
10+
export function deepMap(children, callback) {
11+
return Children.map(children, child => {
12+
// null happens when conditionally rendering TabPanel/Tab
13+
// see https://github.com/reactjs/react-tabs/issues/37
14+
if (child === null) return null;
15+
16+
if (isTabChild(child)) {
17+
return callback(child);
18+
}
19+
20+
if (child.props && child.props.children && typeof child.props.children === 'object') {
21+
// Clone the child that has children and map them too
22+
return cloneElement(child, {
23+
...child.props,
24+
children: deepMap(child.props.children, callback),
25+
});
26+
}
27+
28+
return child;
29+
});
30+
}
31+
32+
export function deepForEach(children, callback) {
33+
return Children.forEach(children, child => {
34+
// null happens when conditionally rendering TabPanel/Tab
35+
// see https://github.com/reactjs/react-tabs/issues/37
36+
if (child === null) return;
37+
38+
if (child.type === Tab || child.type === TabPanel) {
39+
callback(child);
40+
} else if (child.props && child.props.children && typeof child.props.children === 'object') {
41+
if (child.type === TabList) callback(child);
42+
deepForEach(child.props.children, callback);
43+
}
44+
});
45+
}

src/helpers/count.js

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import React from 'react';
2-
import TabList from '../components/TabList';
1+
import { deepForEach } from '../helpers/childrenDeepMap';
32
import Tab from '../components/Tab';
43
import TabPanel from '../components/TabPanel';
54

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

9-
if (tabLists[0] && tabLists[0].props.children) {
10-
return React.Children.count(
11-
React.Children.toArray(tabLists[0].props.children).filter(x => x.type === Tab),
12-
);
13-
}
14-
15-
return 0;
11+
return tabCount;
1612
}
1713

1814
export function getPanelsCount(children) {
19-
return React.Children.count(React.Children.toArray(children).filter(x => x.type === TabPanel));
15+
let panelCount = 0;
16+
deepForEach(children, child => {
17+
if (child.type === TabPanel) panelCount++;
18+
});
19+
20+
return panelCount;
2021
}

src/helpers/propTypes.js

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import { deepForEach } from '../helpers/childrenDeepMap';
22
import Tab from '../components/Tab';
33
import TabList from '../components/TabList';
44
import TabPanel from '../components/TabPanel';
@@ -7,38 +7,36 @@ export function childrenPropType(props, propName, componentName) {
77
let error;
88
let tabsCount = 0;
99
let panelsCount = 0;
10+
let tabListFound = false;
11+
const listTabs = [];
1012
const children = props[propName];
1113

12-
React.Children.forEach(children, child => {
13-
// null happens when conditionally rendering TabPanel/Tab
14-
// see https://github.com/reactjs/react-tabs/issues/37
15-
if (child === null) {
16-
return;
17-
}
18-
14+
deepForEach(children, child => {
1915
if (child.type === TabList) {
20-
React.Children.forEach(child.props.children, c => {
21-
// null happens when conditionally rendering TabPanel/Tab
22-
// see https://github.com/reactjs/react-tabs/issues/37
23-
if (c === null) {
24-
return;
25-
}
16+
if (child.props && child.props.children && typeof child.props.children === 'object') {
17+
deepForEach(child.props.children, listChild => listTabs.push(listChild));
18+
}
2619

27-
if (c.type === Tab) {
28-
tabsCount++;
29-
}
30-
});
20+
if (tabListFound) {
21+
error = new Error(
22+
"Found multiple 'TabList' components inside 'Tabs'. Only one is allowed.",
23+
);
24+
}
25+
tabListFound = true;
26+
}
27+
if (child.type === Tab) {
28+
if (!tabListFound || listTabs.indexOf(child) === -1) {
29+
error = new Error(
30+
"Found a 'Tab' component outside of the 'TabList' component. 'Tab' components have to be inside the 'TabList' component.",
31+
);
32+
}
33+
tabsCount++;
3134
} else if (child.type === TabPanel) {
3235
panelsCount++;
33-
} else {
34-
error = new Error(
35-
`Expected 'TabList' or 'TabPanel' but found '${child.type.displayName ||
36-
child.type}' in \`${componentName}\``,
37-
);
3836
}
3937
});
4038

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

0 commit comments

Comments
 (0)