Skip to content

Commit cebda73

Browse files
committed
feat(tabs): add support for multi row tabs
adds support for multi row tabs #206
1 parent a396762 commit cebda73

File tree

4 files changed

+166
-17
lines changed

4 files changed

+166
-17
lines changed

src/Tab/Tab.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,21 @@ const StyledTab = styled.button`
4040
`
4141
z-index: 1;
4242
height: calc(${blockSizes.md} + 4px);
43-
top: -3px;
43+
top: -4px;
4444
margin-bottom: -6px;
4545
padding: 0 16px;
4646
margin-left: -8px;
47-
margin-right: -8px;
47+
&:not(:last-child) {
48+
margin-right: -8px;
49+
}
4850
`}
4951
&:before {
5052
content: '';
5153
position: absolute;
5254
width: calc(100% - 4px);
5355
height: 6px;
54-
5556
background: ${({ theme }) => theme.material};
56-
bottom: -3px;
57+
bottom: -4px;
5758
left: 2px;
5859
}
5960
`;

src/Tabs/Tabs.js

+61-6
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,50 @@ import styled from 'styled-components';
55

66
const StyledTabs = styled.div`
77
position: relative;
8-
left: 8px;
8+
${({ isMultiRow, theme }) =>
9+
isMultiRow &&
10+
`
11+
button {
12+
flex-grow: 1;
13+
}
14+
button:last-child:before {
15+
border-right: 2px solid ${theme.borderDark};
16+
}
17+
`}
18+
`;
19+
20+
const Row = styled.div.attrs(() => ({
21+
'data-testid': 'tab-row'
22+
}))`
23+
position: relative;
24+
display: flex;
25+
flex-wrap: no-wrap;
926
text-align: left;
27+
left: 8px;
28+
width: calc(100% - 8px);
29+
30+
&:not(:first-child):before {
31+
content: '';
32+
position: absolute;
33+
right: 0;
34+
left: 0;
35+
height: 100%;
36+
border-right: 2px solid ${({ theme }) => theme.borderDarkest};
37+
border-left: 2px solid ${({ theme }) => theme.borderLightest};
38+
}
1039
`;
1140

41+
function splitToChunks(array, parts) {
42+
const result = [];
43+
for (let i = parts; i > 0; i -= 1) {
44+
result.push(array.splice(0, Math.ceil(array.length / i)));
45+
}
46+
return result;
47+
}
48+
1249
const Tabs = React.forwardRef(function Tabs(props, ref) {
13-
const { value, onChange, children, ...otherProps } = props;
50+
const { value, onChange, children, rows, ...otherProps } = props;
51+
1452
const childrenWithProps = React.Children.map(children, child => {
1553
if (!React.isValidElement(child)) {
1654
return null;
@@ -21,22 +59,39 @@ const Tabs = React.forwardRef(function Tabs(props, ref) {
2159
};
2260
return React.cloneElement(child, tabProps);
2361
});
62+
63+
// split tabs into equal rows and assign key to each row
64+
const tabRows = splitToChunks(childrenWithProps, rows).map((tabs, i) => ({
65+
key: i,
66+
tabs
67+
}));
68+
69+
// move row containing currently selected tab to the bottom
70+
const currentlySelectedRowIndex = tabRows.findIndex(tabRow =>
71+
tabRow.tabs.some(tab => tab.props.selected)
72+
);
73+
tabRows.push(tabRows.splice(currentlySelectedRowIndex, 1)[0]);
74+
2475
return (
25-
<StyledTabs {...otherProps} role='tablist' ref={ref}>
26-
{childrenWithProps}
76+
<StyledTabs {...otherProps} isMultiRow={rows > 1} role='tablist' ref={ref}>
77+
{tabRows.map(row => (
78+
<Row key={row.key}>{row.tabs}</Row>
79+
))}
2780
</StyledTabs>
2881
);
2982
});
3083

3184
Tabs.defaultProps = {
3285
onChange: () => {},
33-
children: null
86+
children: null,
87+
rows: 1
3488
};
3589

3690
Tabs.propTypes = {
3791
// eslint-disable-next-line react/require-default-props, react/forbid-prop-types
3892
value: propTypes.any,
3993
onChange: propTypes.func,
40-
children: propTypes.node
94+
children: propTypes.node,
95+
rows: propTypes.number
4196
};
4297
export default Tabs;

src/Tabs/Tabs.spec.js

+51
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,55 @@ describe('<Tabs />', () => {
6464
expect(handleChange.mock.calls[0][1]).toBe(1);
6565
});
6666
});
67+
68+
describe('prop: rows', () => {
69+
it('should render specified number of rows', () => {
70+
const tabs = (
71+
<Tabs value={1} rows={4}>
72+
{/* row 1 */}
73+
<Tab value={0} />
74+
<Tab value={1} />
75+
<Tab value={3} />
76+
77+
{/* row 2 */}
78+
<Tab value={4} />
79+
<Tab value={5} />
80+
81+
{/* row 3 */}
82+
<Tab value={6} />
83+
<Tab value={7} />
84+
85+
{/* row 4 */}
86+
<Tab value={8} />
87+
<Tab value={9} />
88+
</Tabs>
89+
);
90+
const { getAllByTestId } = renderWithTheme(tabs);
91+
const rowElements = getAllByTestId('tab-row');
92+
expect(rowElements.length).toBe(4);
93+
});
94+
95+
it('row containing currently selected tab should be at the bottom (last row)', () => {
96+
const tabs = (
97+
<Tabs value={4} rows={3}>
98+
<Tab value={0} />
99+
<Tab value={1} />
100+
<Tab value={3} />
101+
102+
<Tab value={4} />
103+
<Tab value={5} />
104+
<Tab value={6} />
105+
106+
<Tab value={7} />
107+
<Tab value={8} />
108+
<Tab value={9} />
109+
</Tabs>
110+
);
111+
const { container, getAllByTestId } = renderWithTheme(tabs);
112+
const rowElements = getAllByTestId('tab-row');
113+
const selectedTab = container.querySelector('[aria-selected=true]');
114+
115+
expect(rowElements.pop().contains(selectedTab)).toBe(true);
116+
});
117+
});
67118
});

src/Tabs/Tabs.stories.js

+49-7
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
WindowContent,
1010
Fieldset,
1111
NumberField,
12-
Checkbox
12+
Checkbox,
13+
Anchor
1314
} from 'react95';
1415

1516
export default {
@@ -39,12 +40,7 @@ export const Default = () => {
3940
const { activeTab } = state;
4041
return (
4142
<Window style={{ width: 350 }}>
42-
<WindowHeader>
43-
<span role='img' aria-label='dress'>
44-
👗
45-
</span>
46-
store.exe
47-
</WindowHeader>
43+
<WindowHeader>store.exe</WindowHeader>
4844
<WindowContent>
4945
<Tabs value={activeTab} onChange={handleChange}>
5046
<Tab value={0}>Shoes</Tab>
@@ -87,3 +83,49 @@ export const Default = () => {
8783
Default.story = {
8884
name: 'default'
8985
};
86+
87+
export const MultiRow = () => {
88+
const [state, setState] = useState({
89+
activeTab: 'Shoes'
90+
});
91+
92+
const handleChange = (e, value) => setState({ activeTab: value });
93+
94+
const { activeTab } = state;
95+
return (
96+
<Window style={{ width: 450 }}>
97+
<WindowHeader>store.exe</WindowHeader>
98+
<WindowContent>
99+
<Tabs rows={2} value={activeTab} onChange={handleChange}>
100+
<Tab value='Shoes'>Shoes</Tab>
101+
<Tab value='Accesories'>Accesories</Tab>
102+
<Tab value='Clothing'>Clothing</Tab>
103+
<Tab value='Cars'>Cars</Tab>
104+
<Tab value='Electronics'>Electronics</Tab>
105+
<Tab value='Art'>Art</Tab>
106+
<Tab value='Perfumes'>Perfumes</Tab>
107+
<Tab value='Games'>Games</Tab>
108+
<Tab value='Food'>Food</Tab>
109+
</Tabs>
110+
<TabBody style={{ height: 300 }}>
111+
<p>
112+
Currently active tab: <mark>{activeTab}</mark>
113+
</p>
114+
<br />
115+
<p>
116+
Keep in mind that multi row tabs are{' '}
117+
<Anchor href='http://hallofshame.gp.co.at/tabs.htm' target='_blank'>
118+
REALLY bad UX
119+
</Anchor>
120+
. We&apos;ve added them just because it was a thing back in the day,
121+
but there are better ways to handle navigation with many options.
122+
</p>
123+
</TabBody>
124+
</WindowContent>
125+
</Window>
126+
);
127+
};
128+
129+
MultiRow.story = {
130+
name: 'multi row'
131+
};

0 commit comments

Comments
 (0)