Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/underlinepanels-tabs-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

UnderlinePanels: The experimental `UnderlinePanels` component is now built on the experimental `Tabs` component instead of `@github/tab-container-element`. Its public API and behavior are unchanged.
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
"dependencies": {
"@github/mini-throttle": "^2.1.1",
"@github/relative-time-element": "^5.0.0",
"@github/tab-container-element": "^4.8.2",
"@lit-labs/react": "1.2.1",
"@oddbird/popover-polyfill": "^0.5.2",
"@primer/behaviors": "^1.10.3",
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/experimental/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {useTabPanel} from './useTabPanel'
*/
function Tabs(props: TabsProps) {
const {children, onValueChange} = props
const groupId = useId()
const generatedId = useId()
const groupId = props.id ?? generatedId

const [selectedValue, setSelectedValue] = useControllableState<string>({
name: 'tab-selection',
Expand Down
10 changes: 9 additions & 1 deletion packages/react/src/experimental/Tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@ type UncontrolledTabsProps = {
onValueChange?: ({value}: {value: string}) => void
}

export type TabsProps = PropsWithChildren<ControlledTabsProps | UncontrolledTabsProps>
type CommonTabsProps = {
/**
* Optional id used as the base for generated tab and panel ids. If omitted, a
* unique id is generated automatically.
*/
id?: string
}

export type TabsProps = PropsWithChildren<(ControlledTabsProps | UncontrolledTabsProps) & CommonTabsProps>

type Label = {
'aria-label': string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {useState} from 'react'
import type {ComponentProps} from '../../utils/types'
import type {Meta, StoryFn} from '@storybook/react-vite'
import UnderlinePanels from './UnderlinePanels'
import {AnchoredOverlay} from '../../AnchoredOverlay'
import {Button} from '../../Button'

export default {
title: 'Experimental/Components/UnderlinePanels/Dev',
Expand Down Expand Up @@ -45,3 +48,27 @@ SingleTabPlayground.argTypes = {
},
},
}

export const InOverlay = () => {
const [open, setOpen] = useState(false)

return (
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={props => <Button {...props}>Open panels</Button>}
overlayProps={{role: 'dialog', 'aria-modal': true, 'aria-label': 'Select a tab', style: {width: '320px'}}}
focusZoneSettings={{disabled: true}}
>
<UnderlinePanels aria-label="Select a tab">
<UnderlinePanels.Tab aria-selected={true}>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Tab>Tab 2</UnderlinePanels.Tab>
<UnderlinePanels.Tab>Tab 3</UnderlinePanels.Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
<UnderlinePanels.Panel>Panel 2</UnderlinePanels.Panel>
<UnderlinePanels.Panel>Panel 3</UnderlinePanels.Panel>
</UnderlinePanels>
</AnchoredOverlay>
)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
// Most of the functionality is already tested in [@github/tab-container-element](https://github.com/github/tab-container-element)
// Most of the underlying tab behavior is provided by the experimental `Tabs`
// component and its hooks (see ../Tabs). These tests cover the UnderlinePanels
// public API and its integration with Tabs.

import type React from 'react'
import {act} from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {describe, it, afterEach, beforeEach, expect, vi} from 'vitest'
import {CodeIcon, EyeIcon} from '@primer/octicons-react'
import UnderlinePanels from './UnderlinePanels'
import TabContainerElement from '@github/tab-container-element'
import {implementsClassName, withExpectedConsoleError} from '../../utils/testing'
import classes from './UnderlinePanels.module.css'

TabContainerElement.prototype.selectTab = vi.fn()

const UnderlinePanelsMockComponent = (props: {'aria-label'?: string; 'aria-labelledby'?: string; id?: string}) => (
<UnderlinePanels {...props}>
<UnderlinePanels.Tab>Tab 1</UnderlinePanels.Tab>
Expand All @@ -25,12 +25,34 @@ const UnderlinePanelsMockComponent = (props: {'aria-label'?: string; 'aria-label

describe('UnderlinePanels', () => {
implementsClassName(UnderlinePanels, classes.StyledUnderlineWrapper)
implementsClassName(UnderlinePanels.Tab)
implementsClassName(UnderlinePanels.Panel)
afterEach(() => {
vi.restoreAllMocks()
})

// Tab/Panel require the Tabs context, so they're rendered inside
// UnderlinePanels rather than via `implementsClassName` (which renders alone).
it('UnderlinePanels.Tab renders with a custom className', () => {
const Tab = UnderlinePanels.Tab as React.ElementType
render(
<UnderlinePanels aria-label="Select a tab">
<Tab className="test-class">Tab 1</Tab>
<UnderlinePanels.Panel>Panel 1</UnderlinePanels.Panel>
Comment thread
rickyzhangca marked this conversation as resolved.
</UnderlinePanels>,
)

expect(screen.getByRole('tab', {name: 'Tab 1'})).toHaveClass('test-class')
})
it('UnderlinePanels.Panel renders with a custom className', () => {
render(
<UnderlinePanels aria-label="Select a tab">
<UnderlinePanels.Tab>Tab 1</UnderlinePanels.Tab>
<UnderlinePanels.Panel className="test-class">Panel 1</UnderlinePanels.Panel>
</UnderlinePanels>,
)

expect(screen.getByText('Panel 1')).toHaveClass('test-class')
})

it('renders with a custom ID', () => {
render(<UnderlinePanelsMockComponent aria-label="Select a tab" id="custom-id" />)

Expand Down Expand Up @@ -106,6 +128,27 @@ describe('UnderlinePanels', () => {
expect(onSelect).toHaveBeenCalled()
})

it('selects the first tab by default and hides the other panels', () => {
render(<UnderlinePanelsMockComponent aria-label="Select a tab" />)

expect(screen.getByRole('tab', {name: 'Tab 1'})).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('Panel 1')).toBeVisible()
expect(screen.getByText('Panel 2')).not.toBeVisible()
expect(screen.getByText('Panel 3')).not.toBeVisible()
})

it('switches the visible panel when a tab is selected (uncontrolled)', async () => {
const user = userEvent.setup()
render(<UnderlinePanelsMockComponent aria-label="Select a tab" />)

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

expect(screen.getByRole('tab', {name: 'Tab 2'})).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', {name: 'Tab 1'})).toHaveAttribute('aria-selected', 'false')
expect(screen.getByText('Panel 2')).toBeVisible()
expect(screen.getByText('Panel 1')).not.toBeVisible()
})

it('throws an error when the number of tabs does not match the number of panels', () => {
withExpectedConsoleError(() => {
expect(() => {
Expand Down
Loading
Loading