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

Allow Tabs to be controllable #970

Merged
merged 5 commits into from
Dec 1, 2021
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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Ensure portal root exists in the DOM ([#950](https://github.com/tailwindlabs/headlessui/pull/950))

### Added

- Allow for `Tab.Group` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970))

## [Unreleased - Vue]

- Nothing yet!
### Added

- Allow for `TabGroup` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970))

## [@headlessui/react@v1.4.2] - 2021-11-08

Expand Down
245 changes: 244 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 @@ -415,6 +415,249 @@ describe('Rendering', () => {
assertTabs({ active: 0 })
assertActiveElement(getByText('Tab 1'))
})

it('should not change the Tab if the defaultIndex changes', async () => {
function Example() {
let [defaultIndex, setDefaultIndex] = useState(1)

return (
<>
<Tab.Group defaultIndex={defaultIndex}>
<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={() => setDefaultIndex(0)}>change</button>
</>
)
}

render(<Example />)

assertActiveElement(document.body)

await press(Keys.Tab)

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

await click(getByText('Tab 3'))

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

// Change default index
await click(getByText('change'))

// Nothing should change...
assertTabs({ active: 2 })
})
})

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

function ControlledTabs() {
let [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'))
})

it('should prefer selectedIndex over defaultIndex', async () => {
render(
<>
<Tab.Group selectedIndex={0} defaultIndex={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'))
})
})

describe(`'Tab'`, () => {
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'))

let 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
Loading