Skip to content

Commit

Permalink
feat: Allow a specific Tab to be made active (#1258)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Smedinga <[email protected]>
  • Loading branch information
RubenSibon and VincentSmedinga authored Jun 17, 2024
1 parent 9378432 commit 9d2f01d
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 101 deletions.
108 changes: 106 additions & 2 deletions packages/react/src/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,111 @@ describe('Tabs', () => {
// This feature has not been implemented yet
})

it.skip('should be able to set the active initial tab', () => {
// This feature has not been implemented yet
it('should be able to set the initially active tab', () => {
render(
<Tabs activeTab={2}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

const tabOne = screen.getByRole('tab', { name: 'Tab 1' })
const tabThree = screen.getByRole('tab', { name: 'Tab 3' })

expect(tabOne).toHaveAttribute('aria-selected', 'false')
expect(tabOne).toHaveAttribute('tabindex', '-1')

expect(tabThree).toHaveAttribute('aria-selected', 'true')
expect(tabThree).toHaveAttribute('tabindex', '0')

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 3')
})

it('should set the first tab as the initially active tab with a too small argument', async () => {
render(
<Tabs activeTab={-3}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

const firstTab = screen.getByRole('tab', { name: 'Tab 1' })
const lastTab = screen.getByRole('tab', { name: 'Tab 4' })

expect(firstTab).toHaveAttribute('aria-selected', 'true')
expect(firstTab).toHaveAttribute('tabindex', '0')

expect(lastTab).toHaveAttribute('aria-selected', 'false')
expect(lastTab).toHaveAttribute('tabindex', '-1')

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1')
})

it('should set the last tab as the initially active tab with a too large argument', async () => {
render(
<Tabs activeTab={Infinity}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

const firstTab = screen.getByRole('tab', { name: 'Tab 1' })
const lastTab = screen.getByRole('tab', { name: 'Tab 4' })

expect(firstTab).toHaveAttribute('aria-selected', 'true')
expect(firstTab).toHaveAttribute('tabindex', '0')

expect(lastTab).toHaveAttribute('aria-selected', 'false')
expect(lastTab).toHaveAttribute('tabindex', '-1')

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1')
})

it('should set the first tab as the initially active tab with an invalid number', async () => {
render(
<Tabs activeTab={NaN}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

const firstTab = screen.getByRole('tab', { name: 'Tab 1' })

expect(firstTab).toHaveAttribute('aria-selected', 'true')
expect(firstTab).toHaveAttribute('tabindex', '0')

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1')
})
})
71 changes: 47 additions & 24 deletions packages/react/src/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,64 @@
*/

import clsx from 'clsx'
import { forwardRef, useId, useImperativeHandle, useRef, useState } from 'react'
import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'
import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'
import type { ForwardedRef, HTMLAttributes, PropsWithChildren, ReactNode } from 'react'
import { TabsButton } from './TabsButton'
import { TabsContext } from './TabsContext'
import { TabsList } from './TabsList'
import { TabsPanel } from './TabsPanel'
import { useKeyboardFocus } from '../common/useKeyboardFocus'

export type TabsProps = PropsWithChildren<HTMLAttributes<HTMLDivElement>>
export type TabsProps = {
/** The number of the active tab. Corresponds to its `tab` value. */
activeTab?: number
} & PropsWithChildren<HTMLAttributes<HTMLDivElement>>

const TabsRoot = forwardRef(({ children, className, ...restProps }: TabsProps, ref: ForwardedRef<HTMLDivElement>) => {
const tabsId = useId()
const [activeTab, setActiveTab] = useState(0)
const innerRef = useRef<HTMLDivElement>(null)
const TabsRoot = forwardRef(
({ activeTab, children, className, ...restProps }: TabsProps, ref: ForwardedRef<HTMLDivElement>) => {
const tabsId = useId()
const innerRef = useRef<HTMLDivElement>(null)
const [activeTabId, setActiveTabId] = useState(0)

const updateTab = (tab: number) => {
setActiveTab(tab)
}
const allTabs = useMemo(() => {
if (!Array.isArray(children)) return []
return (children[0].props.children as ReactNode[]).map((child) => child)
}, [children])

// use a passed ref if it's there, otherwise use innerRef
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)
useEffect(() => {
if (typeof activeTab !== 'number') return
if (!Number.isInteger(activeTab)) return

const { keyDown } = useKeyboardFocus(innerRef, {
rotating: true,
horizontally: true,
})
if (activeTab < 0) {
setActiveTabId(0)
} else if (activeTab > allTabs.length - 1) {
setActiveTabId(allTabs.length - 1)
} else {
setActiveTabId(activeTab)
}
}, [activeTab, allTabs])

return (
<TabsContext.Provider value={{ activeTab, updateTab, tabsId }}>
<div {...restProps} role="tabs" ref={innerRef} onKeyDown={keyDown} className={clsx('ams-tabs', className)}>
{children}
</div>
</TabsContext.Provider>
)
})
const updateTab = (tab: number) => {
setActiveTabId(tab)
}

// Use a passed ref if it's there, otherwise use innerRef
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)

const { keyDown } = useKeyboardFocus(innerRef, {
rotating: true,
horizontally: true,
})

return (
<TabsContext.Provider value={{ activeTab: activeTabId, updateTab, tabsId }}>
<div {...restProps} role="tabs" ref={innerRef} onKeyDown={keyDown} className={clsx('ams-tabs', className)}>
{children}
</div>
</TabsContext.Provider>
)
},
)

TabsRoot.displayName = 'Tabs'

Expand Down
12 changes: 6 additions & 6 deletions packages/react/src/Tabs/TabsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@ export type TabsButtonProps = {

export const TabsButton = forwardRef(
({ children, className, tab = 0, ...restProps }: TabsButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
const { activeTab, updateTab, tabsId } = useContext(TabsContext)
const { activeTab, tabsId, updateTab } = useContext(TabsContext)

return (
<button
{...restProps}
role="tab"
id={`${tabsId}-tab-${tab}`}
aria-controls={`${tabsId}-panel-${tab}`}
aria-selected={activeTab === tab}
tabIndex={activeTab === tab ? 0 : -1}
ref={ref}
className={clsx('ams-tabs__button', className)}
id={`${tabsId}-tab-${tab}`}
onClick={() => {
startTransition(() => {
updateTab(tab)
})
}}
className={clsx('ams-tabs__button', className)}
ref={ref}
role="tab"
tabIndex={activeTab === tab ? 0 : -1}
>
{children}
</button>
Expand Down
14 changes: 12 additions & 2 deletions storybook/src/components/Tabs/Tabs.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ import README from "../../../../packages/css/src/components/tabs/README.md?raw";

<Markdown>{README}</Markdown>

## Examples

### Default

Each tab consists of a button and a panel.
A `tab` prop with a corresponding value connects them.

<Primary />

## Tab
### With Initial Tab

The first tab is active by default.
Another tab’s panel can be displayed initially as well.

<Canvas of={TabsStories.Tab} />
<Canvas of={TabsStories.WithInitialTab} />
Loading

0 comments on commit 9d2f01d

Please sign in to comment.