Skip to content

Commit 1c3ca66

Browse files
authored
feat: tabs component (#573)
* feat: tabs component * feat: add stories * test(unit): add tests * chore: move import to first line
1 parent 626667c commit 1c3ca66

File tree

4 files changed

+255
-0
lines changed

4 files changed

+255
-0
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Tabs, TabList, Tab, TabPanel } from './Tabs';
5+
6+
describe('Experimental: Tabs', () => {
7+
it('renders tabs and switch between them', async () => {
8+
const user = userEvent.setup();
9+
render(
10+
<Tabs>
11+
<TabList aria-label="Tabs">
12+
<Tab id="T1">Tab 1</Tab>
13+
<Tab id="T2">Tab 2</Tab>
14+
<Tab id="T3">Tab 3</Tab>
15+
</TabList>
16+
<TabPanel id="T1">Content of Tab 1</TabPanel>
17+
<TabPanel id="T2">Content of Tab 2</TabPanel>
18+
<TabPanel id="T3">Content of Tab 3</TabPanel>
19+
</Tabs>
20+
);
21+
22+
expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute('aria-selected', 'true');
23+
expect(screen.getByText('Content of Tab 1')).toBeInTheDocument();
24+
25+
await user.click(screen.getByRole('tab', { name: 'Tab 3' }));
26+
27+
expect(screen.getByRole('tab', { name: 'Tab 3' })).toHaveAttribute('aria-selected', 'true');
28+
expect(screen.getByText('Content of Tab 3')).toBeInTheDocument();
29+
expect(screen.queryByText('Content of Tab 1')).not.toBeInTheDocument();
30+
});
31+
32+
it('handles disabled tabs', async () => {
33+
const user = userEvent.setup();
34+
render(
35+
<Tabs>
36+
<TabList aria-label="Tabs">
37+
<Tab id="T1">Tab 1</Tab>
38+
<Tab id="T2" isDisabled>
39+
Tab 2
40+
</Tab>
41+
</TabList>
42+
<TabPanel id="T1">Founding.</TabPanel>
43+
<TabPanel id="T2">Monarchy.</TabPanel>
44+
</Tabs>
45+
);
46+
47+
await user.click(screen.getByRole('tab', { name: 'Tab 2' }));
48+
expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute('aria-selected', 'true');
49+
expect(screen.getByRole('tab', { name: 'Tab 2' })).toHaveAttribute('aria-selected', 'false');
50+
});
51+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React, { ReactElement, ComponentType } from 'react';
2+
import {
3+
Tabs as BaseTabs,
4+
TabList as BaseTabList,
5+
Tab as BaseTab,
6+
TabPanel as BaseTabPanel,
7+
TabsProps,
8+
TabListProps,
9+
TabProps,
10+
TabPanelProps
11+
} from 'react-aria-components';
12+
import styled from 'styled-components';
13+
import { get } from '../../../utils/experimental/themeGet';
14+
import { getSemanticValue } from '../../../essentials/experimental';
15+
import { textStyles } from '../Text/Text';
16+
17+
const StyledTabs = styled(BaseTabs as ComponentType<TabsProps>)`
18+
display: flex;
19+
gap: ${get('space.4')};
20+
21+
&[data-orientation='vertical'] {
22+
flex-direction: row;
23+
}
24+
25+
&[data-orientation='horizontal'] {
26+
flex-direction: column;
27+
}
28+
`;
29+
30+
const StyledTabList = styled(BaseTabList as ComponentType<TabListProps<Record<string, unknown>>>)`
31+
display: flex;
32+
gap: ${get('space.4')};
33+
34+
&[data-orientation='vertical'] {
35+
flex-direction: column;
36+
}
37+
38+
&[data-orientation='horizontal'] {
39+
flex-direction: row;
40+
}
41+
`;
42+
43+
const StyledTab = styled(BaseTab as ComponentType<TabProps>)`
44+
position: relative;
45+
cursor: pointer;
46+
outline: none;
47+
padding: ${get('space.2')} 0;
48+
${textStyles.variants.label1};
49+
color: ${getSemanticValue('on-surface-variant')};
50+
transition: color 200ms ease;
51+
52+
display: flex;
53+
align-items: center;
54+
justify-content: center;
55+
56+
&[data-hovered] {
57+
color: ${getSemanticValue('on-surface')};
58+
}
59+
60+
&[data-selected] {
61+
color: ${getSemanticValue('accent')};
62+
}
63+
64+
&[data-disabled] {
65+
color: ${getSemanticValue('on-surface-variant')};
66+
opacity: 0.38;
67+
cursor: default;
68+
}
69+
70+
&::after {
71+
content: '';
72+
position: absolute;
73+
background: ${getSemanticValue('accent')};
74+
opacity: 0;
75+
transition: opacity 200ms ease;
76+
}
77+
78+
[data-orientation='vertical'] &::after {
79+
top: 50%;
80+
transform: translateY(-50%);
81+
right: -1px;
82+
width: 2px;
83+
height: 85%;
84+
}
85+
86+
[data-orientation='horizontal'] &::after {
87+
left: 50%;
88+
transform: translateX(-50%);
89+
bottom: -1px;
90+
height: 2px;
91+
width: 85%;
92+
}
93+
94+
&[data-selected]::after {
95+
opacity: 1;
96+
}
97+
98+
&[data-focus-visible] {
99+
outline: 0.125rem solid ${getSemanticValue('accent')};
100+
outline-offset: 0.125rem;
101+
}
102+
`;
103+
104+
const StyledTabPanel = styled(BaseTabPanel as ComponentType<TabPanelProps>)`
105+
outline: none;
106+
${textStyles.variants.body1};
107+
`;
108+
109+
function Tabs(props: TabsProps): ReactElement {
110+
return <StyledTabs {...props} />;
111+
}
112+
113+
function TabList<T extends Record<string, unknown>>(props: TabListProps<T>): ReactElement {
114+
return <StyledTabList {...props} />;
115+
}
116+
117+
function Tab(props: TabProps): ReactElement {
118+
return <StyledTab {...props} />;
119+
}
120+
121+
function TabPanel(props: TabPanelProps): ReactElement {
122+
return <StyledTabPanel {...props} />;
123+
}
124+
125+
export { Tabs, TabList, Tab, TabPanel };
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from 'react';
2+
import { StoryObj, Meta } from '@storybook/react';
3+
import { Tabs, TabList, Tab, TabPanel } from '../Tabs';
4+
5+
const meta: Meta = {
6+
title: 'Experimental/Components/Tabs',
7+
component: Tabs,
8+
parameters: {
9+
layout: 'centered'
10+
},
11+
argTypes: {
12+
keyboardActivation: {
13+
control: 'radio',
14+
options: ['automatic', 'manual']
15+
},
16+
orientation: {
17+
control: 'radio',
18+
options: ['horizontal', 'vertical']
19+
},
20+
isDisabled: {
21+
control: 'boolean'
22+
}
23+
}
24+
};
25+
26+
export default meta;
27+
28+
type Story = StoryObj<typeof Tabs>;
29+
30+
export const Default: Story = {
31+
render: args => (
32+
<Tabs {...args}>
33+
<TabList aria-label="Tabs">
34+
<Tab id="T1">Tab 1</Tab>
35+
<Tab id="T2">Tab 2</Tab>
36+
<Tab id="T3">Tab 3</Tab>
37+
</TabList>
38+
<TabPanel id="T1">Content of Tab 1</TabPanel>
39+
<TabPanel id="T2">Content of Tab 2</TabPanel>
40+
<TabPanel id="T3">Content of Tab 3</TabPanel>
41+
</Tabs>
42+
)
43+
};
44+
45+
export const DisabledTab: Story = {
46+
render: args => (
47+
<Tabs {...args}>
48+
<TabList aria-label="Tabs">
49+
<Tab id="T1">Tab 1</Tab>
50+
<Tab id="T2" isDisabled>
51+
Tab 2
52+
</Tab>
53+
<Tab id="T3">Tab 3</Tab>
54+
</TabList>
55+
<TabPanel id="T1">Content of Tab 1</TabPanel>
56+
<TabPanel id="T2">Content of Tab 2</TabPanel>
57+
<TabPanel id="T3">Content of Tab 3</TabPanel>
58+
</Tabs>
59+
)
60+
};
61+
62+
export const DisabledTabs: Story = {
63+
args: {
64+
isDisabled: true
65+
},
66+
render: args => (
67+
<Tabs {...args}>
68+
<TabList aria-label="Tabs">
69+
<Tab id="T1">Tab 1</Tab>
70+
<Tab id="T2">Tab 2</Tab>
71+
<Tab id="T3">Tab 3</Tab>
72+
</TabList>
73+
<TabPanel id="T1">Content of Tab 1</TabPanel>
74+
<TabPanel id="T2">Content of Tab 2</TabPanel>
75+
<TabPanel id="T3">Content of Tab 3</TabPanel>
76+
</Tabs>
77+
)
78+
};

src/components/experimental/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { Search } from './Search/Search';
2222
export { Select } from './Select/Select';
2323
export { Snackbar, SnackbarProps } from './Snackbar/Snackbar';
2424
export { Table, Row, Cell, Skeleton, Column, TableBody, TableHeader } from './Table/Table';
25+
export { Tabs, TabList, Tab, TabPanel } from './Tabs/Tabs';
2526
export { Text } from './Text/Text';
2627
export { TextField } from './TextField/TextField';
2728
export { TimeField } from './TimeField/TimeField';

0 commit comments

Comments
 (0)