Skip to content

Commit

Permalink
Allow Tabs to be controllable (#970)
Browse files Browse the repository at this point in the history
* feat(react): Allow Tab Component to be controlled

* fix falsy bug

`selectedIndex || defaultIndex` would result in the `defaultIndex` if
`selectedIndex` is set to 0. This means that if you have this code:

```js
<Tab.Group selectedIndex={0} defaultIndex={2} />
```

That you will never be able to see the very first tab, unless you
provided a negative value like `-1`.

`selectedIndex ?? defaultIndex` fixes this, since it purely checkes for
`undefined` and `null`.

* implemented controllable Tabs for Vue

* add dedicated test to ensure changing the defaultIndex has no effect

* update changelog

Co-authored-by: ChiefORZ <[email protected]>
  • Loading branch information
RobinMalfait and ChiefORZ authored Dec 1, 2021
1 parent 8c57814 commit 8335bee
Show file tree
Hide file tree
Showing 5 changed files with 540 additions and 21 deletions.
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

0 comments on commit 8335bee

Please sign in to comment.