Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): Allow Tab Component to be controlled #909

Closed
wants to merge 1 commit into from
Closed
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
169 changes: 168 additions & 1 deletion packages/@headlessui-react/src/components/tabs/tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { createElement } from 'react'
import React, { createElement, useState } from 'react'
import { render } from '@testing-library/react'

import { Tab } from './tabs'
Expand Down Expand Up @@ -417,6 +417,173 @@ describe('Rendering', () => {
})
})

describe('`selectedIndex`', () => {
it('should be possible to change active tab controlled and uncontrolled', async () => {
let handleChange = jest.fn()

const ControlledTabs = () => {
const [selectedIndex, setSelectedIndex] = useState(0)

return (
<>
<Tab.Group
selectedIndex={selectedIndex}
onChange={value => {
setSelectedIndex(value)
handleChange(value)
}}
>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>

<button>after</button>
<button onClick={() => setSelectedIndex(prev => prev + 1)}>setSelectedIndex</button>
</>
)
}

render(<ControlledTabs />)

assertActiveElement(document.body)

// test uncontrolled behaviour
await click(getByText('Tab 2'))
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenNthCalledWith(1, 1)
assertTabs({ active: 1 })

// test controlled behaviour
await click(getByText('setSelectedIndex'))
assertTabs({ active: 2 })
})

it('should jump to the nearest tab when the selectedIndex is out of bounds (-2)', async () => {
render(
<>
<Tab.Group selectedIndex={-2}>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>

<button>after</button>
</>
)

assertActiveElement(document.body)

await press(Keys.Tab)

assertTabs({ active: 0 })
assertActiveElement(getByText('Tab 1'))
})

it('should jump to the nearest tab when the selectedIndex is out of bounds (+5)', async () => {
render(
<>
<Tab.Group selectedIndex={5}>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>

<button>after</button>
</>
)

assertActiveElement(document.body)

await press(Keys.Tab)

assertTabs({ active: 2 })
assertActiveElement(getByText('Tab 3'))
})

it('should jump to the next available tab when the selectedIndex is a disabled tab', async () => {
render(
<>
<Tab.Group selectedIndex={0}>
<Tab.List>
<Tab disabled>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>

<button>after</button>
</>
)

assertActiveElement(document.body)

await press(Keys.Tab)

assertTabs({ active: 1 })
assertActiveElement(getByText('Tab 2'))
})

it('should jump to the next available tab when the selectedIndex is a disabled tab and wrap around', async () => {
render(
<>
<Tab.Group defaultIndex={2}>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab disabled>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>

<button>after</button>
</>
)

assertActiveElement(document.body)

await press(Keys.Tab)

assertTabs({ active: 0 })
assertActiveElement(getByText('Tab 1'))
})
})

describe(`'Tab'`, () => {
describe('`type` attribute', () => {
it('should set the `type` to "button" by default', async () => {
Expand Down
28 changes: 19 additions & 9 deletions packages/@headlessui-react/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,19 @@ function Tabs<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(
props: Props<TTag, TabsRenderPropArg> & {
defaultIndex?: number
onChange?: (index: number) => void
selectedIndex?: number
vertical?: boolean
manual?: boolean
}
) {
let { defaultIndex = 0, vertical = false, manual = false, onChange, ...passThroughProps } = props
let {
defaultIndex = 0,
vertical = false,
manual = false,
onChange,
selectedIndex = null,
...passThroughProps
} = props
const orientation = vertical ? 'vertical' : 'horizontal'
const activation = manual ? 'manual' : 'auto'

Expand Down Expand Up @@ -161,18 +169,20 @@ function Tabs<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(

useEffect(() => {
if (state.tabs.length <= 0) return
if (state.selectedIndex !== null) return
if (selectedIndex === null && state.selectedIndex !== null) return

let tabs = state.tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[]
let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled'))

const indexToSet = selectedIndex || defaultIndex

// Underflow
if (defaultIndex < 0) {
if (indexToSet < 0) {
dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[0]) })
}

// Overflow
else if (defaultIndex > state.tabs.length) {
else if (indexToSet > state.tabs.length) {
dispatch({
type: ActionTypes.SetSelectedIndex,
index: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
Expand All @@ -181,15 +191,15 @@ function Tabs<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(

// Middle
else {
let before = tabs.slice(0, defaultIndex)
let after = tabs.slice(defaultIndex)
let before = tabs.slice(0, indexToSet)
let after = tabs.slice(indexToSet)

let next = [...after, ...before].find(tab => focusableTabs.includes(tab))
if (!next) return

dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(next) })
}
}, [defaultIndex, state.tabs, state.selectedIndex])
}, [defaultIndex, selectedIndex, state.tabs, state.selectedIndex])

let lastChangedIndex = useRef(state.selectedIndex)
let providerBag = useMemo<ContextType<typeof TabsContext>>(
Expand Down Expand Up @@ -349,7 +359,7 @@ export function Tab<TTag extends ElementType = typeof DEFAULT_TAB_TAG>(
let passThroughProps = props

if (process.env.NODE_ENV === 'test') {
Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex })
Object.assign(propsWeControl, { 'data-headlessui-index': myIndex })
}

return render({
Expand Down Expand Up @@ -424,7 +434,7 @@ function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}

if (process.env.NODE_ENV === 'test') {
Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex })
Object.assign(propsWeControl, { 'data-headlessui-index': myIndex })
}

let passThroughProps = props
Expand Down