Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export * from './text-link'
// form fields
export * from './checkbox-field'
export * from './password-field'
export * from './segmented-control'
export * from './select-field'
export * from './switch-field'
export * from './text-area'
Expand Down
14 changes: 14 additions & 0 deletions src/segmented-control/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type {
AriaDescriptionProps,
AriaLabelProps,
SegmentedControlOption,
SegmentedControlProps,
SegmentedControlRadioOption,
SegmentedControlRadioProps,
} from './segmented-control'
export { SegmentedControl, SegmentedControlRadio } from './segmented-control'
export type {
SegmentedControlTabsOption,
SegmentedControlTabsProps,
} from './segmented-control-tabs'
export { SegmentedControlTabs } from './segmented-control-tabs'
153 changes: 153 additions & 0 deletions src/segmented-control/segmented-control-tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import * as React from 'react'

import { Box } from '../box'

import { SegmentedControlTabs } from './segmented-control-tabs'

import type { Meta, StoryObj } from '@storybook/react'

const meta = {
title: '📑 Menus & tabs/Segmented Control/Tabs',
component: SegmentedControlTabs,
parameters: {
badges: ['accessible'],
figma: {
url: 'https://www.figma.com/design/AH9ioCnZZLfiA5XcHFabOr/-Refresh--Product-%E2%80%93-Web?node-id=5-1187',
path: 'Refresh - Product Web',
},
},
} satisfies Meta<typeof SegmentedControlTabs>

export default meta

type Story = StoryObj<typeof meta>

function ControlledExample({ width }: { width?: 'maxContent' | 'full' }) {
const [selectedOptionId, setSelectedOptionId] = React.useState('all')

return (
<SegmentedControlTabs
aria-label="Notifications"
width={width}
contentOffset="medium"
selectedOptionId={selectedOptionId}
onSelectedOptionChange={setSelectedOptionId}
options={[
{ id: 'all', label: 'All', tabContent: 'All notifications content' },
{ id: 'unread', label: 'Unread', tabContent: 'Unread notifications content' },
]}
/>
)
}

function UpgradeIcon() {
return (
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="m7 1 1.7 3.6L13 5.1 9.8 8l.8 4.2L7 10.1l-3.6 2.1L4.2 8 1 5.1l4.3-.5z"
fill="currentColor"
/>
</svg>
)
}

export const Default: Story = {
render: () => <ControlledExample />,
}

export const FullWidth: Story = {
render: () => (
<Box width="small">
<ControlledExample width="full" />
</Box>
),
}

export const FullWidthWithWrappingLabels: Story = {
render: () => (
<Box width="small">
<SegmentedControlTabs
aria-label="Developer options"
width="full"
initialSelectedOptionId="testdata"
options={[
{ id: 'testdata', label: 'Test Data', tabContent: 'Test data content' },
{ id: 'tooltips', label: 'Tooltips', tabContent: 'Tooltips content' },
{ id: 'syncengine', label: 'Sync Engine', tabContent: 'Sync engine content' },
{ id: 'appversion', label: 'App Version', tabContent: 'App version content' },
{ id: 'frozen', label: 'Frozen Data', tabContent: 'Frozen data content' },
{ id: 'persistence', label: 'Persistence', tabContent: 'Persistence content' },
{
id: 'featureflags',
label: 'Feature Flags',
tabContent: 'Feature flags content',
},
]}
/>
</Box>
),
}

export const DisabledOption: Story = {
render: () => (
<SegmentedControlTabs
aria-label="Notifications"
initialSelectedOptionId="all"
options={[
{ id: 'all', label: 'All', tabContent: 'All notifications content' },
{
id: 'unread',
label: 'Unread',
tabContent: 'Unread notifications content',
disabled: true,
},
]}
/>
),
}

export const WithExtraContent: Story = {
render: () => (
<SegmentedControlTabs
aria-label="Reminder type"
initialSelectedOptionId="date"
options={[
{
id: 'date',
label: 'Date & time',
extraIcon: <UpgradeIcon />,
tabContent: 'Date and time settings',
},
{
id: 'location',
label: 'Location',
extraLabel: '2',
tabContent: 'Location settings',
},
]}
/>
),
}

export const WithLabelsAsLinks: Story = {
render: () => (
<SegmentedControlTabs
aria-label="Integrations"
initialSelectedOptionId="installed"
options={[
{
id: 'installed',
label: 'Installed',
tabContent: 'Installed integrations',
tabRender: <a href="#installed" />,
},
{
id: 'browse',
label: 'Browse',
tabContent: 'Browse integrations',
tabRender: <a href="#browse" />,
},
]}
/>
),
}
148 changes: 148 additions & 0 deletions src/segmented-control/segmented-control-tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import * as React from 'react'

import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe } from 'jest-axe'

import { flushMicrotasks, TestIcon } from '../utils/test-helpers'

import { SegmentedControlTabs } from './segmented-control-tabs'

async function flushAnimationFrames() {
await act(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
)
}

async function renderSegmentedControlTabs(element: React.ReactElement) {
const result = render(element)
await flushAnimationFrames()
return result
}

describe('SegmentedControlTabs', () => {
it('renders option metadata and switches controlled panels', async () => {
const onSelectedOptionChange = jest.fn()
const user = userEvent.setup()

function Example() {
const [selectedOptionId, setSelectedOptionId] = React.useState('all')

return (
<SegmentedControlTabs
aria-label="Notifications"
selectedOptionId={selectedOptionId}
onSelectedOptionChange={(id) => {
onSelectedOptionChange(id)
setSelectedOptionId(id)
}}
options={[
{ id: 'all', label: 'All', tabContent: 'All notifications' },
{
id: 'unread',
label: 'Unread',
extraLabel: '2',
extraIcon: <TestIcon />,
tabContent: 'Unread notifications',
},
]}
/>
)
}

await renderSegmentedControlTabs(<Example />)

expect(await screen.findByRole('tabpanel', { name: 'All' })).toBeVisible()
expect(screen.getByRole('tab', { name: 'Unread 2' })).toBeVisible()

await user.click(screen.getByRole('tab', { name: 'Unread 2' }))
await flushMicrotasks()

expect(onSelectedOptionChange).toHaveBeenCalledWith('unread')
expect(screen.getByRole('tabpanel', { name: 'Unread 2' })).toBeVisible()
expect(screen.getByText('Unread notifications')).toBeVisible()
})

it('renders tabs as links and supports content wrappers', async () => {
function ContentWrapper({ children }: { children: React.ReactNode }) {
return <span title="Wrapped tab">{children}</span>
}

await renderSegmentedControlTabs(
<SegmentedControlTabs
aria-label="Integrations"
initialSelectedOptionId="installed"
options={[
{
id: 'installed',
label: 'Installed',
tabContent: 'Installed integrations',
tabRender: <a href="#installed" />,
},
{
id: 'browse',
label: 'Browse',
tabContent: 'Browse integrations',
Wrapper: ContentWrapper,
},
]}
/>,
)

expect(await screen.findByRole('tab', { name: 'Installed' })).toHaveAttribute(
'href',
'#installed',
)
expect(screen.getByTitle('Wrapped tab')).toBeVisible()
})

it('removes disabled tabs from keyboard navigation', async () => {
const user = userEvent.setup()

await renderSegmentedControlTabs(
<SegmentedControlTabs
aria-label="Notifications"
initialSelectedOptionId="all"
options={[
{ id: 'all', label: 'All', tabContent: 'All notifications' },
{
id: 'unread',
label: 'Unread',
tabContent: 'Unread notifications',
disabled: true,
},
{ id: 'mentions', label: 'Mentions', tabContent: 'Mentions' },
]}
/>,
)

await screen.findByRole('tabpanel', { name: 'All' })
await user.tab()
expect(screen.getByRole('tab', { name: 'All' })).toHaveFocus()

await user.keyboard('{ArrowRight}')
await flushMicrotasks()

expect(screen.getByRole('tab', { name: 'Mentions' })).toHaveFocus()
expect(screen.getByRole('tab', { name: 'Unread' })).toBeDisabled()
})

it('has no automated accessibility violations', async () => {
const { container } = await renderSegmentedControlTabs(
<SegmentedControlTabs
aria-label="Notifications"
initialSelectedOptionId="all"
options={[
{ id: 'all', label: 'All', tabContent: 'All notifications' },
{ id: 'unread', label: 'Unread', tabContent: 'Unread notifications' },
]}
/>,
)

await screen.findByRole('tabpanel', { name: 'All' })
expect(await axe(container)).toHaveNoViolations()
})
})
Loading
Loading