Skip to content

Commit 01b16db

Browse files
adierkensjoshblackCopilot
authored
Add Tabs component (#7123)
Co-authored-by: Josh Black <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 57ffdbc commit 01b16db

File tree

12 files changed

+1230
-5
lines changed

12 files changed

+1230
-5
lines changed

.changeset/crazy-bees-travel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Adds an experimental `Tabs` utility component & associated hooks

e2e/components/Axe.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ const SKIPPED_TESTS = [
1818

1919
type Component = {
2020
name: string
21+
type: 'story' | 'docs'
2122
}
2223

2324
const {entries} = componentsConfig
2425

2526
test.describe('Axe tests', () => {
2627
for (const [id, entry] of Object.entries(entries as Record<string, Component>)) {
27-
if (SKIPPED_TESTS.includes(id)) {
28+
if (SKIPPED_TESTS.includes(id) || entry.type !== 'story') {
2829
continue
2930
}
3031

packages/react/.storybook/preview.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const preview = {
5555
[
5656
'*',
5757
// Within a set of stories, set the order to the following
58-
['*', 'Playground', /Playground$/, 'Features', 'Examples'],
58+
['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'],
5959
],
6060
],
6161
],
@@ -72,7 +72,7 @@ const preview = {
7272
[
7373
'*',
7474
// Within a set of stories, set the order to the following
75-
['*', 'Playground', /Playground$/, 'Features', 'Examples'],
75+
['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'],
7676
],
7777
],
7878
],
@@ -92,7 +92,7 @@ const preview = {
9292
[
9393
'*',
9494
// Within a set of stories, set the order to the following
95-
['*', 'Playground', /Playground$/, 'Features', 'Examples'],
95+
['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'],
9696
],
9797
],
9898
],
@@ -110,7 +110,7 @@ const preview = {
110110
[
111111
'*',
112112
// Within a set of stories, set the order to the following
113-
['*', 'Playground', /Playground$/, 'Features', 'Examples'],
113+
['README', '*', 'Playground', /Playground$/, 'Features', 'Examples'],
114114
],
115115
],
116116
],

packages/react/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,11 @@ exports[`@primer/react/experimental > should not update exports without a semver
348348
"type TableRowProps",
349349
"type TableSubtitleProps",
350350
"type TableTitleProps",
351+
"type TabListProps",
352+
"type TabPanelProps",
353+
"type TabProps",
354+
"Tabs",
355+
"type TabsProps",
351356
"type TitleProps",
352357
"Tooltip",
353358
"type TooltipProps",
@@ -359,5 +364,8 @@ exports[`@primer/react/experimental > should not update exports without a semver
359364
"useFeatureFlag",
360365
"useOverflow",
361366
"useSlots",
367+
"useTab",
368+
"useTabList",
369+
"useTabPanel",
362370
]
363371
`;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {Canvas, Meta} from '@storybook/addon-docs/blocks'
2+
3+
import * as TabsStories from './Tabs.stories'
4+
import * as TabsExamples from './Tabs.examples.stories'
5+
6+
<Meta of={TabsStories} />
7+
8+
# Tabs
9+
10+
The `Tabs` component is a headless component that provides the logic and state management for building tabbed interfaces. It allows users to switch between different views or sections of content within the same context.
11+
12+
The primary responsibility of the `Tabs` component is to manage the active tab state, handle keyboard navigation, and ensure accessibility compliance. It does not include any styling or visual representation, allowing developers to customize the appearance according to their design requirements.
13+
14+
<Canvas of={TabsStories.Default} />
15+
16+
## Using `Tabs`
17+
18+
To use the `Tabs` component, you need to import it along with its associated utility hooks: `useTabList`, `useTab`, and `useTabPanel`. These hooks help generate the props needed to create the necessary elements for the tabbed interface.
19+
20+
Simply call these hooks and spread the returned props onto the elements of your choosing.
21+
22+
```tsx
23+
import React from 'react'
24+
import {Tabs, useTabList, useTab, useTabPanel} from '@primer/react/experimental'
25+
26+
function TabPanel({children, ...props}) {
27+
const tabPanelProps = useTabPanel(props)
28+
return <div {...tabPanelProps}>{children}</div>
29+
}
30+
31+
function Tab({children, ...props}) {
32+
const tabProps = useTab(props)
33+
return <button {...tabProps}>{children}</button>
34+
}
35+
36+
function TabList({children, ...props}) {
37+
const tabListProps = useTabList(props)
38+
return <div {...tabListProps}>{children}</div>
39+
}
40+
41+
export function MyTabs() {
42+
return (
43+
<Tabs defaultValue="tab1">
44+
<TabList>
45+
<Tab value="tab1">Tab 1</Tab>
46+
<Tab value="tab2">Tab 2</Tab>
47+
</TabList>
48+
<TabPanel value="tab1">
49+
<p>This is the content for Tab 1.</p>
50+
</TabPanel>
51+
<TabPanel value="tab2">
52+
<p>This is the content for Tab 2.</p>
53+
</TabPanel>
54+
</Tabs>
55+
)
56+
}
57+
```
58+
59+
All styling and layout is left up to you!
60+
61+
This approach provides maximum flexibility, allowing you to create tabbed interfaces that fit seamlessly into your application's design while leveraging the robust functionality provided by the `Tabs` component.
62+
63+
### Example: `ActionList`
64+
65+
<Canvas of={TabsExamples.WithCustomComponents} />
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type {Meta} from '@storybook/react-vite'
2+
import {action} from 'storybook/actions'
3+
import React from 'react'
4+
import {Tabs, TabPanel, useTabList, useTab} from './Tabs'
5+
import {ActionList} from '../../ActionList'
6+
import Flash from '../../Flash'
7+
8+
const meta = {
9+
title: 'Experimental/Components/Tabs/Examples',
10+
component: Tabs,
11+
} satisfies Meta<typeof Tabs>
12+
13+
export default meta
14+
15+
const CustomTabList = (props: React.PropsWithChildren) => {
16+
const {tabListProps} = useTabList<HTMLUListElement>({'aria-label': 'Tabs', 'aria-orientation': 'vertical'})
17+
18+
return (
19+
<div style={{width: '200px'}}>
20+
<ActionList {...tabListProps}>{props.children}</ActionList>
21+
</div>
22+
)
23+
}
24+
25+
const CustomTab = (props: React.PropsWithChildren<{value: string; disabled?: boolean}>) => {
26+
const {tabProps} = useTab({value: props.value, disabled: props.disabled})
27+
28+
return (
29+
<ActionList.Item {...tabProps} active={String(tabProps['aria-selected']) === 'true'}>
30+
{props.children}
31+
</ActionList.Item>
32+
)
33+
}
34+
35+
export const WithCustomComponents = () => {
36+
const [value, setValue] = React.useState('one')
37+
return (
38+
<>
39+
<Flash style={{marginBottom: '16px'}}>
40+
This example shows how to use the `Tabs` component with custom Components for the TabList and Tabs. Here we are
41+
using `ActionList` and `ActionList.Item`
42+
<br />
43+
The direction is also set to `vertical` to demonstrate the `aria-orientation` prop handling. Which also changes
44+
the keyboard navigation to Up/Down arrows.
45+
</Flash>
46+
47+
<div
48+
style={{
49+
display: 'grid',
50+
gridTemplateColumns: 'auto 1fr',
51+
}}
52+
>
53+
<Tabs
54+
value={value}
55+
onValueChange={({value}) => {
56+
action('onValueChange')({value})
57+
setValue(value)
58+
}}
59+
>
60+
<CustomTabList>
61+
<CustomTab value="one">One</CustomTab>
62+
<CustomTab value="two">Two</CustomTab>
63+
<CustomTab value="three">Three</CustomTab>
64+
<CustomTab disabled value="four">
65+
Four
66+
</CustomTab>
67+
</CustomTabList>
68+
<TabPanel value="one">Panel one</TabPanel>
69+
<TabPanel value="two">Panel two</TabPanel>
70+
<TabPanel value="three">Panel three</TabPanel>
71+
<TabPanel value="four">Panel four</TabPanel>
72+
</Tabs>
73+
</div>
74+
<button
75+
type="button"
76+
onClick={() => {
77+
setValue('three')
78+
}}
79+
>
80+
Activate panel three
81+
</button>
82+
</>
83+
)
84+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type {Meta} from '@storybook/react-vite'
2+
import {action} from 'storybook/actions'
3+
import React from 'react'
4+
import {Tabs, TabList, Tab, TabPanel} from './Tabs'
5+
import Flash from '../../Flash'
6+
7+
const meta = {
8+
title: 'Experimental/Components/Tabs/Features',
9+
component: Tabs,
10+
} satisfies Meta<typeof Tabs>
11+
12+
export default meta
13+
14+
export const Uncontrolled = () => (
15+
<Tabs
16+
defaultValue="one"
17+
onValueChange={({value}) => {
18+
action('onValueChange')({value})
19+
}}
20+
>
21+
<TabList aria-label="Tabs">
22+
<Tab value="one">One</Tab>
23+
<Tab value="two">Two</Tab>
24+
<Tab value="three">Three</Tab>
25+
</TabList>
26+
<TabPanel value="one">Panel one</TabPanel>
27+
<TabPanel value="two">Panel two</TabPanel>
28+
<TabPanel value="three">Panel three</TabPanel>
29+
</Tabs>
30+
)
31+
32+
export const Controlled = () => {
33+
const [value, setValue] = React.useState('one')
34+
return (
35+
<>
36+
<Tabs
37+
value={value}
38+
onValueChange={({value}) => {
39+
action('onValueChange')({value})
40+
setValue(value)
41+
}}
42+
>
43+
<TabList aria-label="Tabs">
44+
<Tab value="one">One</Tab>
45+
<Tab value="two">Two</Tab>
46+
<Tab value="three">Three</Tab>
47+
</TabList>
48+
<TabPanel value="one">Panel one</TabPanel>
49+
<TabPanel value="two">Panel two</TabPanel>
50+
<TabPanel value="three">Panel three</TabPanel>
51+
</Tabs>
52+
<button
53+
type="button"
54+
onClick={() => {
55+
setValue('three')
56+
}}
57+
>
58+
Activate panel three
59+
</button>
60+
</>
61+
)
62+
}
63+
64+
export const Vertical = () => (
65+
<>
66+
<Flash style={{marginBottom: '16px'}}>
67+
This example shows the `Tabs` component with `aria-orientation` set to `vertical`, which changes the keyboard
68+
navigation to Up/Down arrows.
69+
</Flash>
70+
<div
71+
style={{
72+
display: 'grid',
73+
gridTemplateColumns: 'auto 1fr',
74+
}}
75+
>
76+
<Tabs
77+
defaultValue="one"
78+
onValueChange={({value}) => {
79+
action('onValueChange')({value})
80+
}}
81+
>
82+
<TabList
83+
aria-orientation="vertical"
84+
style={{
85+
display: 'flex',
86+
flexDirection: 'column',
87+
}}
88+
aria-label="Tabs"
89+
>
90+
<Tab value="one">One</Tab>
91+
<Tab value="two">Two</Tab>
92+
<Tab value="three">Three</Tab>
93+
</TabList>
94+
<TabPanel value="one">Panel one</TabPanel>
95+
<TabPanel value="two">Panel two</TabPanel>
96+
<TabPanel value="three">Panel three</TabPanel>
97+
</Tabs>
98+
</div>
99+
</>
100+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {Meta} from '@storybook/react-vite'
2+
import React from 'react'
3+
import {Tabs, TabList, Tab, TabPanel} from './Tabs'
4+
5+
const meta = {
6+
title: 'Experimental/Components/Tabs',
7+
component: Tabs,
8+
} satisfies Meta<typeof Tabs>
9+
10+
export default meta
11+
12+
export const Default = () => {
13+
return (
14+
<Tabs defaultValue="one">
15+
<TabList aria-label="Tabs">
16+
<Tab value="one">One</Tab>
17+
<Tab value="two">Two</Tab>
18+
<Tab value="three">Three</Tab>
19+
</TabList>
20+
<TabPanel value="one">Panel one</TabPanel>
21+
<TabPanel value="two">Panel two</TabPanel>
22+
<TabPanel value="three">Panel three</TabPanel>
23+
</Tabs>
24+
)
25+
}

0 commit comments

Comments
 (0)